diff --git a/.env.example b/.env.example index 93200c63..c90aa4c0 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,12 @@ GOOGLE_BOOKS_API_KEY= # optional — see https://codebude.github.io/libri CORS_ORIGINS=["http://localhost", "http://localhost:5173"] LOG_LEVEL=INFO # DEBUG | INFO | WARNING | ERROR | CRITICAL COVERS_DIR=./data/covers # Directory where downloaded cover images are stored locally. +AVATARS_DIR=./data/avatars # Directory where uploaded and cached OIDC profile pictures are stored. +AVATAR_MAX_UPLOAD_MB=10 +AVATAR_MAX_DIMENSION=512 +AVATAR_QUALITY=90 +AVATAR_FETCH_TIMEOUT_SECONDS=10 +AVATAR_FETCH_MAX_BYTES=20000000 # Generate a real secret: openssl rand -base64 32 API_KEY_ENCRYPTION_KEY=CHANGE_ME_TO_32PLUS_CHARS # <-- YOU MUST CHANGE THIS AUTH_COOKIE_NAME=librislog_session @@ -35,6 +41,10 @@ OIDC_CLIENT_ID= OIDC_CLIENT_SECRET= OIDC_WELL_KNOWN_URL= OIDC_SCOPE="openid email profile" +# Public origin the browser uses for OIDC callbacks, without a path. Set this when +# the backend sees a different host, for example behind the Vite dev proxy, which +# rewrites Host to localhost. Leave empty to build the URI from the request. +# OIDC_CALLBACK_BASE_URL=http://dev.example.lan:5173 # Dashboard quote settings DASHBOARD_QUOTE_ENABLED=true diff --git a/backend/alembic/versions/b4c5d6e7f8a9_add_user_image_and_avatar_source.py b/backend/alembic/versions/b4c5d6e7f8a9_add_user_image_and_avatar_source.py new file mode 100644 index 00000000..f08ac951 --- /dev/null +++ b/backend/alembic/versions/b4c5d6e7f8a9_add_user_image_and_avatar_source.py @@ -0,0 +1,66 @@ +"""add user image table and avatar source + +Revision ID: b4c5d6e7f8a9 +Revises: a3b4c5d6e7f8 +Create Date: 2026-10-30 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "b4c5d6e7f8a9" +down_revision: Union[str, Sequence[str], None] = "a3b4c5d6e7f8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table("user") as batch_op: + batch_op.add_column( + sa.Column( + "avatar_source", + sa.Enum("animal", "oidc", "upload", name="avatarsource"), + nullable=False, + server_default="animal", + ) + ) + batch_op.create_index(op.f("ix_user_avatar_source"), ["avatar_source"], unique=False) + + op.create_table( + "user_image", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("kind", sa.String(), nullable=False, server_default="avatar"), + sa.Column("variant", sa.String(), nullable=False), + sa.Column("filename", sa.String(), nullable=True), + sa.Column("external_url", sa.String(), nullable=True), + sa.Column("content_type", sa.String(), nullable=True), + sa.Column("byte_size", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["user.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "user_id", + "kind", + "variant", + name="uq_user_image_user_kind_variant", + ), + ) + op.create_index(op.f("ix_user_image_user_id"), "user_image", ["user_id"], unique=False) + op.create_index(op.f("ix_user_image_kind"), "user_image", ["kind"], unique=False) + op.create_index(op.f("ix_user_image_variant"), "user_image", ["variant"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_user_image_variant"), table_name="user_image") + op.drop_index(op.f("ix_user_image_kind"), table_name="user_image") + op.drop_index(op.f("ix_user_image_user_id"), table_name="user_image") + op.drop_table("user_image") + + with op.batch_alter_table("user") as batch_op: + batch_op.drop_index(op.f("ix_user_avatar_source")) + batch_op.drop_column("avatar_source") diff --git a/backend/app/config.py b/backend/app/config.py index 782a79ce..00fb7ac9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -14,6 +14,12 @@ class Settings(BaseSettings): cors_origins: List[str] = ["http://localhost", "http://localhost:5173", "http://localhost:4173"] log_level: str = "INFO" covers_dir: str = "./data/covers" + avatars_dir: str = "./data/avatars" + avatar_max_upload_mb: int = 10 + avatar_max_dimension: int = 512 + avatar_quality: int = 90 + avatar_fetch_timeout_seconds: float = 10.0 + avatar_fetch_max_bytes: int = 20_000_000 api_key_encryption_key: str auth_cookie_name: str = "librislog_session" auth_cookie_secure: bool = False @@ -33,6 +39,9 @@ class Settings(BaseSettings): oidc_client_secret: str = "" oidc_well_known_url: str = "" oidc_scope: str = "openid email profile" + # Public origin (scheme, host, optional port) under which the browser reaches + # the OIDC callback routes. Empty builds the redirect URI from the request. + oidc_callback_base_url: str = "" dashboard_quote_enabled: bool = True dashboard_quote_url: str = ( "https://motivational-spark-api.vercel.app/api/quotes/random" diff --git a/backend/app/main.py b/backend/app/main.py index d11b347e..5c5036dc 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,6 +18,7 @@ from app.config import settings from app.logging_config import configure_logging from app.routers import admin, auth, books, config, cover_candidates, covers, data, docs, embed, health, hygiene, import_, oidc, profile, progress, public_profile, sessions, share_links, statistics, tags, users +from app.services.avatar_storage import cleanup_orphan_avatars from app.services.cover_storage import cleanup_orphan_covers from app.services.data_import import cleanup_temp_files from app.services.telemetry import send_telemetry_once @@ -38,6 +39,7 @@ async def _periodic_maintenance(interval_hours: int = 1) -> None: Tasks: - Clean up stale temporary import files. - Delete orphaned cover files no longer referenced by any book. + - Delete orphaned avatar files no longer referenced by any user image. Args: interval_hours: Hours between cleanup cycles. Defaults to 1. @@ -57,6 +59,15 @@ async def _periodic_maintenance(interval_hours: int = 1) -> None: if deleted: logger.info("Orphaned cover cleanup: deleted %d file(s)", deleted) + deleted_avatars = await loop.run_in_executor( + None, cleanup_orphan_avatars, session + ) + if deleted_avatars: + logger.info( + "Orphaned avatar cleanup: deleted %d file(s)", + deleted_avatars, + ) + failures = 0 except Exception as exc: failures += 1 @@ -121,6 +132,7 @@ async def lifespan(app: FastAPI): """Application lifespan: create required directories and start background tasks.""" _validate_import_sources() Path(settings.covers_dir).mkdir(parents=True, exist_ok=True) + Path(settings.avatars_dir).mkdir(parents=True, exist_ok=True) Path(settings.import_temp_dir).mkdir(parents=True, exist_ok=True) cleanup_temp_files() @@ -131,6 +143,7 @@ async def lifespan(app: FastAPI): ) oidc.warn_about_loopback_public_app_url() + oidc.warn_about_invalid_oidc_callback_base_url() maintenance_task = asyncio.create_task(_periodic_maintenance()) telemetry_task = ( diff --git a/backend/app/models.py b/backend/app/models.py index 9f47850e..21dc4eb4 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -88,6 +88,14 @@ class UserRole(str, Enum): user = "user" +class AvatarSource(str, Enum): + """Selected source for a user's profile picture.""" + + animal = "animal" + oidc = "oidc" + upload = "upload" + + class Book(SQLModel, table=True): """A book in the user's library.""" @@ -188,6 +196,7 @@ class User(SQLModel, table=True): lastname: str email: str = Field(index=True, unique=True) role: UserRole = Field(default=UserRole.user, index=True) + avatar_source: AvatarSource = Field(default=AvatarSource.animal, index=True) hashed_password: str credentials_version: int = Field( default=0, @@ -228,6 +237,44 @@ class UserSettings(SQLModel, table=True): statistics_custom_to: Optional[date] = Field(default=None) +class UserImage(SQLModel, table=True): + """A locally cached or uploaded image owned by a user.""" + + __tablename__: str = "user_image" + __table_args__ = ( + sa.UniqueConstraint("user_id", "kind", "variant", name="uq_user_image_user_kind_variant"), + ) + + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field( + sa_column=Column( + sa.Integer, + sa.ForeignKey("user.id"), + index=True, + nullable=False, + ), + ) + kind: str = Field(default="avatar", index=True) + variant: str = Field(index=True) + filename: Optional[str] = None + external_url: Optional[str] = None + content_type: Optional[str] = None + byte_size: Optional[int] = None + created_at: datetime = Field( + default_factory=utcnow, + sa_column=Column(UtcDateTime, default=utcnow, nullable=False), + ) + updated_at: datetime = Field( + default_factory=utcnow, + sa_column=Column( + UtcDateTime, + default=utcnow, + onupdate=utcnow, + nullable=False, + ), + ) + + class ApiKey(SQLModel, table=True): """API key for programmatic access.""" diff --git a/backend/app/oidc.py b/backend/app/oidc.py index 0788fd68..c85133f1 100644 --- a/backend/app/oidc.py +++ b/backend/app/oidc.py @@ -1,6 +1,7 @@ """OIDC / SSO integration — provider registration and client creation.""" from typing import Optional +from urllib.parse import urlparse from authlib.integrations.starlette_client import OAuth, StarletteOAuth2App from authlib.oauth2.client import OAuth2Client @@ -23,6 +24,15 @@ def oidc_is_enabled() -> bool: ) +def oidc_provider_host() -> str | None: + """Return the OIDC provider hostname from the well-known URL.""" + try: + hostname = urlparse(settings.oidc_well_known_url).hostname + except ValueError: + return None + return hostname.lower() if hostname else None + + def get_oidc_client() -> Optional[StarletteOAuth2App]: """Return the OIDC client, registering it on first call. diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index affd86ff..765bd8de 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -34,6 +34,7 @@ def backup_download( data_dir=settings.data_dir, covers_dir=settings.covers_dir, import_temp_dir=settings.import_temp_dir, + avatars_dir=settings.avatars_dir, ) except RuntimeError as exc: if "already in progress" in str(exc): @@ -97,6 +98,7 @@ def restore( data_dir=settings.data_dir, covers_dir=settings.covers_dir, import_temp_dir=settings.import_temp_dir, + avatars_dir=settings.avatars_dir, ) except RuntimeError as exc: if "already in progress" in str(exc): diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index a5129e3c..33ad0df6 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -24,6 +24,7 @@ UserLogin, UserRead, ) +from app.services.avatar import build_user_read from app.time_utils import utcnow router = APIRouter(prefix="/api/auth", tags=["auth"]) @@ -78,7 +79,7 @@ def setup( session.commit() start_browser_session(http_request, user.id, user.credentials_version) - return {"user": UserRead.model_validate(user)} + return {"user": build_user_read(session, user)} @router.post("/login") @@ -95,7 +96,7 @@ def login( assert user.id is not None start_browser_session(http_request, user.id, user.credentials_version) - return {"user": UserRead.model_validate(user)} + return {"user": build_user_read(session, user)} @router.post("/logout") @@ -106,9 +107,12 @@ def logout(http_request: Request) -> dict: @router.get("/me", response_model=UserRead) -def me(current_user: User = Depends(require_user)) -> User: +def me( + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> UserRead: """Return the currently authenticated user.""" - return current_user + return build_user_read(session, current_user) @router.get("/csrf") diff --git a/backend/app/routers/config.py b/backend/app/routers/config.py index 015aacf4..21239695 100644 --- a/backend/app/routers/config.py +++ b/backend/app/routers/config.py @@ -19,4 +19,6 @@ def app_config() -> AppConfigRead: password_login_enabled=settings.password_login_enabled, # Report the effective state: a misconfigured proxy feature is inactive. trusted_proxy_auth_enabled=proxy_auth.is_active(), + avatar_max_upload_mb=settings.avatar_max_upload_mb, + avatar_max_dimension=settings.avatar_max_dimension, ) diff --git a/backend/app/routers/oidc.py b/backend/app/routers/oidc.py index 0f405f05..19bbd373 100644 --- a/backend/app/routers/oidc.py +++ b/backend/app/routers/oidc.py @@ -1,7 +1,7 @@ """OIDC SSO endpoints — login, callback, link, unlink, and status.""" import logging -from urllib.parse import quote_plus, urlparse +from urllib.parse import quote_plus, urlparse, urlunsplit from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import RedirectResponse @@ -10,10 +10,22 @@ from app.auth import require_user from app.auth import start_browser_session from app.database import get_session -from app.models import OidcLink, User -from app.oidc import get_oidc_client, oidc_is_enabled +from app.models import AvatarSource, OidcLink, User +from app.oidc import get_oidc_client, oidc_is_enabled, oidc_provider_host from app.config import settings from app.schemas import OidcConfigRead, OidcLinkRead +from app.services.avatar import ( + VARIANT_OIDC, + VARIANT_UPLOAD, + get_user_image, + image_has_file, + upsert_avatar_image, +) +from app.services.avatar_storage import ( + cache_oidc_avatar, + delete_avatar_file, + is_safe_oidc_picture_url, +) from app.time_utils import utcnow router = APIRouter(prefix="/api/oidc", tags=["oidc"]) @@ -30,6 +42,61 @@ def _provider_id() -> str: return settings.oidc_provider_id +async def _sync_oidc_avatar( + session: Session, + user: User, + picture: str | None, + *, + force_cache: bool = False, +) -> str | None: + """Update the user's OIDC avatar row from a ``picture`` claim. + + Returns the previous filename when it was replaced and should be deleted + after the transaction commits. + """ + if user.id is None: + return None + + existing = get_user_image(session, user.id, VARIANT_OIDC) + previous = existing.filename if existing else None + + if not picture: + if existing: + existing.external_url = None + session.add(existing) + return None + + if not is_safe_oidc_picture_url(picture, oidc_provider_host()): + logger.warning("Ignoring unsafe OIDC picture URL: %s", picture) + return None + + needs_cache = force_cache or user.avatar_source == AvatarSource.oidc + if not needs_cache and (existing is None or not existing.filename): + # A user who linked OIDC before this feature existed still has no cached + # file. Cache it on the next login so the profile can offer the option. + needs_cache = True + + stored = None + if needs_cache: + stored = await cache_oidc_avatar( + picture, + settings.avatars_dir, + user.id, + trusted_private_host=oidc_provider_host(), + ) + + upsert_avatar_image( + session, + user.id, + VARIANT_OIDC, + stored, + external_url=picture, + ) + if previous and stored and previous != stored.filename: + return previous + return None + + _LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"} @@ -117,6 +184,28 @@ def _link_error_redirect(message: str, request: Request) -> RedirectResponse: ) +def _callback_base_url() -> str: + """Return the configured OIDC callback origin, or an empty string.""" + return (settings.oidc_callback_base_url or "").strip().rstrip("/") + + +def _is_valid_callback_base_url(base: str) -> bool: + """A usable base is an absolute http(s) URL without a path of its own.""" + parts = urlparse(base) + return parts.scheme in {"http", "https"} and bool(parts.netloc) and parts.path in {"", "/"} + + +def warn_about_invalid_oidc_callback_base_url() -> None: + """Log a startup hint when ``OIDC_CALLBACK_BASE_URL`` cannot be used.""" + base = _callback_base_url() + if base and not _is_valid_callback_base_url(base): + logger.warning( + "OIDC_CALLBACK_BASE_URL=%s is not an absolute http(s) URL such as " + "https://books.example.com; OIDC callbacks fall back to the request host.", + base, + ) + + def _resolve_callback_redirect_uri(request: Request, route_name: str) -> str: """Resolve the callback redirect URI via the ASGI router. @@ -124,8 +213,22 @@ def _resolve_callback_redirect_uri(request: Request, route_name: str) -> str: ``request.scope["scheme"]`` from ``X-Forwarded-Proto``, so ``url_for`` returns the correct scheme (``https``) behind a TLS termination proxy. + + ``OIDC_CALLBACK_BASE_URL`` replaces the public scheme and host when the + backend sees a different origin than the browser, for example behind the + Vite dev proxy, which rewrites the Host header to ``localhost``. """ - return str(request.url_for(route_name)) + callback_url = request.url_for(route_name) + base = _callback_base_url() + if not base: + return str(callback_url) + if not _is_valid_callback_base_url(base): + logger.warning( + "Ignoring invalid OIDC_CALLBACK_BASE_URL=%s; using the request host", base + ) + return str(callback_url) + parts = urlparse(base) + return urlunsplit((parts.scheme, parts.netloc, callback_url.path, "", "")) @router.get("/config", response_model=OidcConfigRead) @@ -181,6 +284,14 @@ async def oidc_callback( try: token = await client.authorize_access_token(request) except Exception: + state = request.query_params.get("state", "") + logger.debug( + "OIDC callback failed: host=%s scheme=%s cookies=%s state_in_session=%s", + request.url.hostname, + request.url.scheme, + sorted(request.cookies.keys()), + bool(state) and f"_state_{_provider_id()}_{state}" in request.session, + ) logger.exception("OIDC authorize_access_token failed during login callback") return _frontend_warning_redirect("OIDC login failed", request) @@ -213,6 +324,11 @@ async def oidc_callback( return _frontend_warning_redirect("Linked user account no longer exists", request) assert user.id is not None + replaced = await _sync_oidc_avatar(session, user, userinfo.get("picture")) + session.commit() + if replaced: + delete_avatar_file(replaced, settings.avatars_dir) + start_browser_session(request, user.id, user.credentials_version) return _frontend_success_redirect(request) @@ -339,7 +455,19 @@ async def oidc_link_callback( ) ) + linked_user = session.get(User, user_id) + replaced = None + if linked_user is not None: + replaced = await _sync_oidc_avatar( + session, + linked_user, + userinfo.get("picture"), + force_cache=True, + ) + session.commit() + if replaced: + delete_avatar_file(replaced, settings.avatars_dir) return _link_success_redirect(request) @@ -349,7 +477,26 @@ def oidc_unlink( session: Session = Depends(get_session), ) -> None: """Unlink the OIDC account from the current user.""" + assert current_user.id is not None link = session.exec(select(OidcLink).where(OidcLink.user_id == current_user.id)).first() if link: session.delete(link) - session.commit() + + oidc_image = get_user_image(session, current_user.id, VARIANT_OIDC) + removed_filename = oidc_image.filename if oidc_image is not None else None + if oidc_image is not None: + session.delete(oidc_image) + + if current_user.avatar_source == AvatarSource.oidc: + upload = get_user_image(session, current_user.id, VARIANT_UPLOAD) + current_user.avatar_source = ( + AvatarSource.upload + if image_has_file(upload, settings.avatars_dir) + else AvatarSource.animal + ) + current_user.updated_at = utcnow() + session.add(current_user) + + session.commit() + if removed_filename: + delete_avatar_file(removed_filename, settings.avatars_dir) diff --git a/backend/app/routers/profile.py b/backend/app/routers/profile.py index 4358f08d..21ad9afb 100644 --- a/backend/app/routers/profile.py +++ b/backend/app/routers/profile.py @@ -1,9 +1,11 @@ """User profile endpoints — profile, settings, data reset, account deletion, API keys.""" import logging +from datetime import timedelta -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile from sqlmodel import Session, col, select +from starlette.concurrency import run_in_threadpool from app.auth import ( clear_browser_session, @@ -19,11 +21,14 @@ ) from app.config import settings as app_settings from app.database import get_session -from app.models import ApiKey, EmbedToken, User, UserSettings +from app.models import ApiKey, AvatarSource, EmbedToken, User, UserImage, UserSettings +from app.oidc import oidc_provider_host from app.schemas import ( ApiKeyCreate, ApiKeyCreateResponse, ApiKeyRead, + AvatarOptionsRead, + AvatarSourceUpdate, ConfirmationPhrase, DataResetDeleted, DataResetResponse, @@ -37,6 +42,21 @@ UserSettingsRead, UserSettingsUpdate, ) +from app.services.avatar import ( + VARIANT_OIDC, + VARIANT_UPLOAD, + build_avatar_options, + build_user_read, + delete_avatar_image, + get_user_image, + image_has_file, + upsert_avatar_image, +) +from app.services.avatar_storage import ( + cache_oidc_avatar, + delete_avatar_file, + save_uploaded_avatar, +) from app.services.statistics import MAX_CUSTOM_RANGE_DAYS from app.time_utils import utcnow from app.services.user_deletion import ( @@ -51,6 +71,17 @@ RESET_DATA_PHRASE: str = "DELETE ALL MY DATA" DELETE_ACCOUNT_PHRASE: str = "DELETE MY ACCOUNT" +_AVATAR_WRITE_INTERVAL = timedelta(seconds=1) + + +def _ensure_avatar_write_interval(image: UserImage | None) -> None: + """Reject avatar replacements that arrive faster than the write interval.""" + if image is not None and image.updated_at is not None: + if utcnow() - image.updated_at < _AVATAR_WRITE_INTERVAL: + raise HTTPException( + status_code=429, + detail="Avatar was updated too recently. Try again in a moment.", + ) def _validate_confirmation(confirmation: str, expected_phrase: str) -> None: @@ -60,9 +91,12 @@ def _validate_confirmation(confirmation: str, expected_phrase: str) -> None: @router.get("", response_model=UserRead) -def get_profile(current_user: User = Depends(require_user)) -> User: +def get_profile( + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> UserRead: """Return the current user's profile.""" - return current_user + return build_user_read(session, current_user) @router.patch("", response_model=UserRead) @@ -70,7 +104,7 @@ def update_profile( user_in: ProfileUpdate, current_user: User = Depends(require_user), session: Session = Depends(get_session), -) -> User: +) -> UserRead: """Update the current user's profile fields.""" update_data = user_in.model_dump(exclude_unset=True) if "password" in update_data: @@ -82,7 +116,180 @@ def update_profile( session.add(current_user) session.commit() session.refresh(current_user) - return current_user + return build_user_read(session, current_user) + + +@router.get("/avatar", response_model=AvatarOptionsRead) +def get_avatar_options( + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> AvatarOptionsRead: + """Return the current avatar state for the profile card.""" + return build_avatar_options(session, current_user, app_settings.avatars_dir) + + +@router.patch("/avatar", response_model=AvatarOptionsRead) +async def update_avatar_source( + body: AvatarSourceUpdate, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> AvatarOptionsRead: + """Change the selected avatar source.""" + assert current_user.id is not None + + if body.source == AvatarSource.upload: + image = get_user_image(session, current_user.id, VARIANT_UPLOAD) + if image is None or not image.filename: + raise HTTPException(status_code=400, detail="No uploaded avatar available") + elif body.source == AvatarSource.oidc: + image = get_user_image(session, current_user.id, VARIANT_OIDC) + if image is None: + raise HTTPException( + status_code=400, + detail="No OIDC picture available. Link OIDC or set a provider picture first.", + ) + if not image.filename and image.external_url: + stored = await cache_oidc_avatar( + image.external_url, + app_settings.avatars_dir, + current_user.id, + trusted_private_host=oidc_provider_host(), + ) + if stored is None: + raise HTTPException( + status_code=400, + detail="OIDC picture could not be downloaded", + ) + upsert_avatar_image( + session, + current_user.id, + VARIANT_OIDC, + stored, + external_url=image.external_url, + ) + elif not image.filename: + raise HTTPException(status_code=400, detail="OIDC picture is not cached") + + current_user.avatar_source = body.source + current_user.updated_at = utcnow() + session.add(current_user) + session.commit() + session.refresh(current_user) + return build_avatar_options(session, current_user, app_settings.avatars_dir) + + +@router.post("/avatar/upload", response_model=AvatarOptionsRead) +async def upload_avatar( + file: UploadFile = File(...), + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> AvatarOptionsRead: + """Upload a cropped avatar image.""" + assert current_user.id is not None + previous = get_user_image(session, current_user.id, VARIANT_UPLOAD) + _ensure_avatar_write_interval(previous) + + limit = app_settings.avatar_max_upload_mb * 1024 * 1024 + body = await file.read(limit + 1) + if len(body) > limit: + raise HTTPException(status_code=413, detail="Avatar image is too large") + + stored = await run_in_threadpool( + save_uploaded_avatar, + body, + file.content_type or "", + app_settings.avatars_dir, + current_user.id, + ) + if stored is None: + raise HTTPException( + status_code=422, + detail="Avatar must be a valid PNG, JPEG, or WebP image", + ) + + previous_filename = previous.filename if previous else None + upsert_avatar_image( + session, + current_user.id, + VARIANT_UPLOAD, + stored, + ) + current_user.avatar_source = AvatarSource.upload + current_user.updated_at = utcnow() + session.add(current_user) + session.commit() + session.refresh(current_user) + if previous_filename and previous_filename != stored.filename: + delete_avatar_file(previous_filename, app_settings.avatars_dir) + return build_avatar_options(session, current_user, app_settings.avatars_dir) + + +@router.delete("/avatar/upload", response_model=AvatarOptionsRead) +def delete_uploaded_avatar( + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> AvatarOptionsRead: + """Remove the uploaded avatar and fall back to OIDC or animal.""" + assert current_user.id is not None + filename = delete_avatar_image( + session, + current_user.id, + VARIANT_UPLOAD, + ) + + if current_user.avatar_source == AvatarSource.upload: + oidc = get_user_image(session, current_user.id, VARIANT_OIDC) + current_user.avatar_source = ( + AvatarSource.oidc + if image_has_file(oidc, app_settings.avatars_dir) + else AvatarSource.animal + ) + + current_user.updated_at = utcnow() + session.add(current_user) + session.commit() + session.refresh(current_user) + if filename: + delete_avatar_file(filename, app_settings.avatars_dir) + return build_avatar_options(session, current_user, app_settings.avatars_dir) + + +@router.post("/avatar/refresh-oidc", response_model=AvatarOptionsRead) +async def refresh_oidc_avatar( + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> AvatarOptionsRead: + """Re-download the cached OIDC picture from its provider URL.""" + assert current_user.id is not None + image = get_user_image(session, current_user.id, VARIANT_OIDC) + if image is None or not image.external_url: + raise HTTPException(status_code=400, detail="No OIDC picture URL is stored") + _ensure_avatar_write_interval(image) + + stored = await cache_oidc_avatar( + image.external_url, + app_settings.avatars_dir, + current_user.id, + trusted_private_host=oidc_provider_host(), + ) + if stored is None: + raise HTTPException(status_code=400, detail="OIDC picture could not be downloaded") + + previous = image.filename + upsert_avatar_image( + session, + current_user.id, + VARIANT_OIDC, + stored, + external_url=image.external_url, + ) + current_user.updated_at = utcnow() + session.add(current_user) + session.commit() + session.refresh(current_user) + if previous and previous != stored.filename: + delete_avatar_file(previous, app_settings.avatars_dir) + return build_avatar_options(session, current_user, app_settings.avatars_dir) @router.get("/settings", response_model=UserSettingsRead) @@ -233,12 +440,15 @@ def delete_own_account( assert current_user.id is not None try: - delete_user_account_data(session, current_user, app_settings.covers_dir) + deleted = delete_user_account_data(session, current_user, app_settings.covers_dir) session.commit() except Exception: session.rollback() raise + for filename in deleted.avatar_filenames: + delete_avatar_file(filename, app_settings.avatars_dir) + clear_browser_session(request) logger.warning("User %s deleted own account", current_user.id) diff --git a/backend/app/routers/public_profile.py b/backend/app/routers/public_profile.py index 86ca5c31..40f20977 100644 --- a/backend/app/routers/public_profile.py +++ b/backend/app/routers/public_profile.py @@ -8,10 +8,12 @@ import logging from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response, Security +from fastapi.responses import FileResponse from fastapi.security import APIKeyHeader from sqlmodel import Session, col, select from app.auth import hash_public_profile_token, require_user +from app.config import settings from app.database import get_session from app.models import PublicProfileAudience, PublicProfileLink, User from app.schemas import ( @@ -19,8 +21,11 @@ PublicProfileResponse, PublicProfileSectionKey, PublicProfileUserInfo, + PublicProfileVisibilityConfig, StatisticsRange, ) +from app.services.avatar import public_avatar_url, selected_avatar_image +from app.services.avatar_storage import resolve_avatar_path from app.services.public_profile import ( BOOK_SECTIONS, build_public_books, @@ -62,20 +67,12 @@ def get_optional_user( return None -@router.get("/{token}", response_model=PublicProfileResponse) -def get_public_profile( +def _resolve_public_link( token: str, - response: Response, - viewer: User | None = Depends(get_optional_user), - session: Session = Depends(get_session), -) -> PublicProfileResponse: - """Return the whitelisted public profile for a share-link token. - - Invalid, expired, or revoked tokens all yield HTTP 404 so that link - existence cannot be probed. Tokens restricted to logged-in users yield - HTTP 401 for anonymous viewers. - """ - _with_security_headers(response) + viewer: User | None, + session: Session, +) -> tuple[PublicProfileLink, User, PublicProfileVisibilityConfig]: + """Validate a public link and return the link, owner, and visibility config.""" link = session.exec( select(PublicProfileLink).where( PublicProfileLink.token_hash == hash_public_profile_token(token), @@ -100,6 +97,24 @@ def get_public_profile( assert owner.id is not None config = parse_visibility_config(link.visibility_config_json) + return link, owner, config + + +@router.get("/{token}", response_model=PublicProfileResponse) +def get_public_profile( + token: str, + response: Response, + viewer: User | None = Depends(get_optional_user), + session: Session = Depends(get_session), +) -> PublicProfileResponse: + """Return the whitelisted public profile for a share-link token. + + Invalid, expired, or revoked tokens all yield HTTP 404 so that link + existence cannot be probed. Tokens restricted to logged-in users yield + HTTP 401 for anonymous viewers. + """ + _with_security_headers(response) + link, owner, config = _resolve_public_link(token, viewer, session) visible_sections = set(config.sections) books: list[PublicProfileBook] = [] @@ -120,11 +135,16 @@ def get_public_profile( visible_sections & {PublicProfileSectionKey.username, PublicProfileSectionKey.user_info} ) + show_avatar = PublicProfileSectionKey.avatar in visible_sections + avatar_url = None + if show_avatar: + avatar_url = public_avatar_url(token, selected_avatar_image(session, owner)) return PublicProfileResponse( owner=PublicProfileUserInfo( firstname=owner.firstname if show_name else None, lastname=owner.lastname if show_name else None, + avatar_url=avatar_url, ), audience=link.audience, language=link.language, @@ -135,6 +155,43 @@ def get_public_profile( ) +@router.get("/{token}/avatar") +def get_public_avatar( + token: str, + viewer: User | None = Depends(get_optional_user), + session: Session = Depends(get_session), +) -> FileResponse: + """Serve the owner's avatar for a public profile link.""" + link, owner, config = _resolve_public_link(token, viewer, session) + visible_sections = set(config.sections) + if PublicProfileSectionKey.avatar not in visible_sections: + raise HTTPException(status_code=404, detail="Avatar not found") + + image = selected_avatar_image(session, owner) + if image is None or not image.filename: + raise HTTPException(status_code=404, detail="Avatar not found") + + path = resolve_avatar_path(image.filename, settings.avatars_dir) + if path is None or not path.is_file(): + raise HTTPException(status_code=404, detail="Avatar not found") + + cache_scope = ( + "private" + if link.audience == PublicProfileAudience.authenticated + else "public" + ) + return FileResponse( + path, + media_type="image/webp", + headers={ + "Cache-Control": f"{cache_scope}, max-age=300", + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "Referrer-Policy": "no-referrer", + }, + ) + + def _with_security_headers(response: Response) -> Response: """Apply baseline security headers to the unauthenticated profile response.""" response.headers["X-Content-Type-Options"] = "nosniff" diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index 19a28b9c..64a707a1 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -1,16 +1,26 @@ """Admin user management endpoints — list, create, update, delete users.""" from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import FileResponse from sqlmodel import Session, col, select from app.auth import ( ensure_password_complexity, get_password_hash, require_admin, + require_user, ) +from app.config import settings from app.database import get_session from app.models import ApiKey, User, UserRole, UserSettings from app.schemas import UserAdminUpdate, UserCreate, UserRead +from app.services.avatar import ( + build_user_read, + delete_all_user_avatar_images, + load_avatar_urls, + selected_avatar_image, +) +from app.services.avatar_storage import delete_avatar_file, resolve_avatar_path from app.services.user_deletion import assert_not_last_admin from app.time_utils import utcnow @@ -21,10 +31,25 @@ def list_users( _admin: User = Depends(require_admin), session: Session = Depends(get_session), -) -> list[User]: +) -> list[UserRead]: """List all users (admin only).""" users = session.exec(select(User).order_by(col(User.created_at))).all() - return list(users) + avatar_urls = load_avatar_urls(session, users) + result: list[UserRead] = [] + for user in users: + assert user.id is not None + result.append( + UserRead( + id=user.id, + firstname=user.firstname, + lastname=user.lastname, + email=user.email, + role=user.role, + created_at=user.created_at, + avatar_url=avatar_urls.get(user.id), + ) + ) + return result @router.post("", status_code=201) @@ -54,7 +79,8 @@ def create_user( session.add(UserSettings(user_id=user.id, language="en")) session.commit() - return {"user": UserRead.model_validate(user)} + session.refresh(user) + return {"user": build_user_read(session, user)} @router.patch("/{user_id}", response_model=UserRead) @@ -89,7 +115,39 @@ def update_user( session.add(user) session.commit() session.refresh(user) - return user + return build_user_read(session, user) + + +@router.get("/{user_id}/avatar") +def get_user_avatar( + user_id: int, + current_user: User = Depends(require_user), + session: Session = Depends(get_session), +) -> FileResponse: + """Serve a user's effective avatar image.""" + if current_user.id != user_id and current_user.role != UserRole.admin: + raise HTTPException(status_code=404, detail="Avatar not found") + + user = session.get(User, user_id) + if user is None: + raise HTTPException(status_code=404, detail="User not found") + + image = selected_avatar_image(session, user) + if image is None or not image.filename: + raise HTTPException(status_code=404, detail="Avatar not found") + + path = resolve_avatar_path(image.filename, settings.avatars_dir) + if path is None or not path.is_file(): + raise HTTPException(status_code=404, detail="Avatar not found") + + return FileResponse( + path, + media_type="image/webp", + headers={ + "Cache-Control": "private, max-age=3600", + "X-Content-Type-Options": "nosniff", + }, + ) @router.delete("/{user_id}", status_code=204) @@ -116,9 +174,13 @@ def delete_user( key.revoked_at = utcnow() session.add(key) - settings = session.exec(select(UserSettings).where(UserSettings.user_id == user_id)).first() - if settings: - session.delete(settings) + user_settings = session.exec(select(UserSettings).where(UserSettings.user_id == user_id)).first() + if user_settings: + session.delete(user_settings) + + avatar_filenames = delete_all_user_avatar_images(session, user_id) session.delete(user) session.commit() + for filename in avatar_filenames: + delete_avatar_file(filename, settings.avatars_dir) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 2d472090..29025674 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -10,7 +10,7 @@ from sqlmodel import Field, SQLModel from sqlmodel._compat import SQLModelConfig -from app.models import AcquisitionStatus, Medium, ReadingStatus, PublicProfileAudience, UserRole +from app.models import AcquisitionStatus, AvatarSource, Medium, ReadingStatus, PublicProfileAudience, UserRole class ReadingProgressCreate(SQLModel): @@ -496,6 +496,7 @@ class UserRead(SQLModel): email: str role: UserRole created_at: datetime + avatar_url: Optional[str] = None class UserUpdate(SQLModel): @@ -524,6 +525,24 @@ class UserAdminUpdate(SQLModel): password: Optional[str] = None +class AvatarSourceUpdate(SQLModel): + """Request body for changing the selected avatar source.""" + + model_config = SQLModelConfig(extra="forbid") + + source: AvatarSource + + +class AvatarOptionsRead(SQLModel): + """Current avatar state for the profile avatar card.""" + + source: AvatarSource + avatar_url: Optional[str] = None + has_uploaded_avatar: bool = False + has_oidc_avatar: bool = False + oidc_linked: bool = False + + class UserSettingsRead(SQLModel): """User settings read response.""" user_id: int @@ -668,6 +687,8 @@ class AppConfigRead(SQLModel): thalia_cover_search_enabled: bool password_login_enabled: bool trusted_proxy_auth_enabled: bool + avatar_max_upload_mb: int = 10 + avatar_max_dimension: int = 512 class OidcLinkRead(SQLModel): @@ -896,6 +917,7 @@ class PublicProfileSectionKey(str, Enum): username = "username" user_info = "user_info" + avatar = "avatar" currently_reading = "currently_reading" last_read = "last_read" reading_timeline = "reading_timeline" @@ -992,6 +1014,7 @@ class PublicProfileUserInfo(SQLModel): firstname: str | None = None lastname: str | None = None + avatar_url: Optional[str] = None class PublicProfileBook(SQLModel): diff --git a/backend/app/services/avatar.py b/backend/app/services/avatar.py new file mode 100644 index 00000000..ba10c275 --- /dev/null +++ b/backend/app/services/avatar.py @@ -0,0 +1,227 @@ +"""Database and domain helpers for user avatar images.""" + +from typing import Iterable + +from sqlalchemy.exc import IntegrityError +from sqlmodel import Session, select + +from app.models import AvatarSource, OidcLink, User, UserImage +from app.schemas import AvatarOptionsRead, UserRead +from app.services.avatar_storage import StoredAvatar, resolve_avatar_path +from app.time_utils import utcnow + +KIND_AVATAR = "avatar" +VARIANT_UPLOAD = "upload" +VARIANT_OIDC = "oidc" + + +def get_user_image( + session: Session, + user_id: int, + variant: str, + kind: str = KIND_AVATAR, +) -> UserImage | None: + """Return the user's image row for *kind* and *variant*, if any.""" + return session.exec( + select(UserImage).where( + UserImage.user_id == user_id, + UserImage.kind == kind, + UserImage.variant == variant, + ) + ).first() + + +def selected_avatar_image(session: Session, user: User) -> UserImage | None: + """Return the image row selected by ``user.avatar_source``.""" + if user.id is None or user.avatar_source == AvatarSource.animal: + return None + variant = VARIANT_OIDC if user.avatar_source == AvatarSource.oidc else VARIANT_UPLOAD + return get_user_image(session, user.id, variant) + + +def image_has_file(image: UserImage | None, avatars_dir: str) -> bool: + if image is None or not image.filename: + return False + path = resolve_avatar_path(image.filename, avatars_dir) + return path is not None and path.is_file() + + +def _apply_avatar_fields( + image: UserImage, + stored: StoredAvatar | None, + external_url: str | None, +) -> UserImage: + if stored is not None: + image.filename = stored.filename + image.content_type = stored.content_type + image.byte_size = stored.byte_size + image.external_url = external_url + image.updated_at = utcnow() + return image + + +def upsert_avatar_image( + session: Session, + user_id: int, + variant: str, + stored: StoredAvatar | None = None, + external_url: str | None = None, + kind: str = KIND_AVATAR, +) -> UserImage: + """Create or update the avatar row for *variant*. + + ``stored`` may be ``None`` when only the provider URL is known and the + download failed or was not requested. The previous file is not deleted + here. Callers should delete the old filename after the transaction + commits, or leave it for the periodic orphan cleanup. This keeps a + rollback from leaving the user with a row that points to a missing file. + """ + for attempt in range(2): + try: + # A savepoint keeps a concurrent-duplicate retry from rolling back + # unrelated pending work (for example a new OidcLink row). + with session.begin_nested(): + image = get_user_image(session, user_id, variant, kind=kind) + if image is None: + image = UserImage( + user_id=user_id, + kind=kind, + variant=variant, + external_url=external_url, + ) + session.add(image) + _apply_avatar_fields(image, stored, external_url) + session.add(image) + session.flush() + return image + except IntegrityError: + # Two concurrent logins or uploads may both try to insert the same + # (user_id, kind, variant). The conflicting row is now visible, so + # retry once and update it instead. + if attempt == 1: + raise + raise RuntimeError("unreachable") + + +def delete_avatar_image( + session: Session, + user_id: int, + variant: str, + kind: str = KIND_AVATAR, +) -> str | None: + """Delete an avatar row and return its filename for post-commit cleanup.""" + image = get_user_image(session, user_id, variant, kind=kind) + if image is None: + return None + filename = image.filename + session.delete(image) + session.flush() + return filename + + +def delete_all_user_avatar_images( + session: Session, + user_id: int, + kind: str = KIND_AVATAR, +) -> list[str]: + """Delete every avatar row for *user_id* and return filenames to clean up.""" + images = session.exec( + select(UserImage).where( + UserImage.user_id == user_id, + UserImage.kind == kind, + ) + ).all() + filenames: list[str] = [] + for image in images: + if image.filename: + filenames.append(image.filename) + session.delete(image) + session.flush() + return filenames + + +def avatar_version(image: UserImage | None) -> int | None: + """Return a cache-busting version for *image*.""" + if image is None or image.updated_at is None: + return None + return int(image.updated_at.timestamp() * 1000) + + +def user_avatar_url(user_id: int, image: UserImage | None) -> str | None: + """Return the authenticated avatar URL for *user_id*, or None.""" + if image is None or not image.filename: + return None + version = avatar_version(image) + suffix = f"?v={version}" if version is not None else "" + return f"/api/users/{user_id}/avatar{suffix}" + + +def public_avatar_url(token: str, image: UserImage | None) -> str | None: + """Return the public avatar URL for a share-link *token*, or None.""" + if image is None or not image.filename: + return None + version = avatar_version(image) + suffix = f"?v={version}" if version is not None else "" + return f"/api/public-profiles/{token}/avatar{suffix}" + + +def build_user_read(session: Session, user: User) -> UserRead: + """Build a ``UserRead`` including the effective avatar URL.""" + image = selected_avatar_image(session, user) + return UserRead( + id=user.id, + firstname=user.firstname, + lastname=user.lastname, + email=user.email, + role=user.role, + created_at=user.created_at, + avatar_url=user_avatar_url(user.id, image) if user.id is not None else None, + ) + + +def load_avatar_urls( + session: Session, + users: Iterable[User], +) -> dict[int, str | None]: + """Return one avatar URL per user without querying once per user.""" + user_list = [user for user in users if user.id is not None] + if not user_list: + return {} + + user_ids = [user.id for user in user_list] + images = session.exec( + select(UserImage).where( + UserImage.user_id.in_(user_ids), + UserImage.kind == KIND_AVATAR, + ) + ).all() + by_user_variant = {(image.user_id, image.variant): image for image in images} + + urls: dict[int, str | None] = {} + for user in user_list: + assert user.id is not None + if user.avatar_source == AvatarSource.animal: + urls[user.id] = None + continue + variant = VARIANT_OIDC if user.avatar_source == AvatarSource.oidc else VARIANT_UPLOAD + urls[user.id] = user_avatar_url(user.id, by_user_variant.get((user.id, variant))) + return urls + + +def build_avatar_options(session: Session, user: User, avatars_dir: str) -> AvatarOptionsRead: + """Build the profile avatar card state for *user*.""" + assert user.id is not None + upload = get_user_image(session, user.id, VARIANT_UPLOAD) + oidc = get_user_image(session, user.id, VARIANT_OIDC) + oidc_link = session.exec( + select(OidcLink).where(OidcLink.user_id == user.id) + ).first() + selected = selected_avatar_image(session, user) + selected_has_file = image_has_file(selected, avatars_dir) + return AvatarOptionsRead( + source=user.avatar_source, + avatar_url=user_avatar_url(user.id, selected) if selected_has_file else None, + has_uploaded_avatar=image_has_file(upload, avatars_dir), + has_oidc_avatar=image_has_file(oidc, avatars_dir), + oidc_linked=oidc_link is not None, + ) diff --git a/backend/app/services/avatar_storage.py b/backend/app/services/avatar_storage.py new file mode 100644 index 00000000..5152bdc4 --- /dev/null +++ b/backend/app/services/avatar_storage.py @@ -0,0 +1,325 @@ +"""Local avatar image storage and normalisation. + +Avatars are decoded with Pillow, normalised to a square WebP file, and written +atomically to ``settings.avatars_dir``. The database row lives in +``app.services.avatar``. +""" + +import hashlib +import io +import logging +import os +import time +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlparse + +import httpx +from PIL import Image, ImageOps, UnidentifiedImageError +from sqlmodel import Session, select +from starlette.concurrency import run_in_threadpool + +from app.config import settings +from app.models import UserImage +from app.services.cover_import import is_safe_cover_import_url + +logger = logging.getLogger(__name__) + +_ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"} +_ALLOWED_PIL_FORMATS = {"JPEG", "PNG", "WEBP"} +_MAX_PICTURE_URL_LENGTH = 2048 +# Hard cap before decoding. Pillow raises above twice this value. +_MAX_IMAGE_PIXELS = 40_000_000 + + +@dataclass +class StoredAvatar: + """Metadata for a normalised avatar file on disk.""" + + filename: str + content_type: str + byte_size: int + + +def safe_avatar_filename(filename: str | None) -> str | None: + """Validate an avatar filename and reject path traversal attempts.""" + if not filename or "/" in filename or "\\" in filename or ".." in filename: + return None + return filename + + +def resolve_avatar_path(filename: str | None, avatars_dir: str | Path) -> Path | None: + """Return the absolute path for a safe avatar filename.""" + safe_filename = safe_avatar_filename(filename) + if not safe_filename: + return None + return Path(avatars_dir) / safe_filename + + +def delete_avatar_file(filename: str, avatars_dir: str | Path) -> bool: + """Delete an avatar file if it exists.""" + path = resolve_avatar_path(filename, avatars_dir) + if path is None: + return False + + try: + path.unlink(missing_ok=True) + except OSError as exc: + logger.warning("Failed to delete avatar %s: %s", filename, exc) + return False + + logger.debug("Deleted avatar: %s", filename) + return True + + +def _normalise_image(body: bytes) -> tuple[bytes, int] | None: + """Decode, crop, resize, and encode an image as WebP. + + Returns ``(webp_bytes, byte_size)`` or ``None`` when the bytes are not a + supported, decodable image. + """ + if not body: + return None + + Image.MAX_IMAGE_PIXELS = _MAX_IMAGE_PIXELS + try: + with Image.open(io.BytesIO(body)) as source: + if source.format not in _ALLOWED_PIL_FORMATS: + logger.warning("Unsupported avatar image format: %s", source.format) + return None + + if source.width * source.height > _MAX_IMAGE_PIXELS: + logger.warning( + "Avatar image is too large to decode: %dx%d", + source.width, + source.height, + ) + return None + + image = ImageOps.exif_transpose(source) + image.load() + + if image.mode in {"RGBA", "LA"} or ( + image.mode == "P" and "transparency" in image.info + ): + image = image.convert("RGBA") + else: + image = image.convert("RGB") + + width, height = image.size + if width <= 0 or height <= 0: + return None + + edge = min(width, height) + left = (width - edge) // 2 + top = (height - edge) // 2 + image = image.crop((left, top, left + edge, top + edge)) + + max_dimension = max(1, settings.avatar_max_dimension) + if image.width > max_dimension or image.height > max_dimension: + image = image.resize( + (max_dimension, max_dimension), + Image.Resampling.LANCZOS, + ) + + output = io.BytesIO() + image.save( + output, + format="WEBP", + quality=max(1, min(100, settings.avatar_quality)), + method=6, + ) + encoded = output.getvalue() + if not encoded: + return None + return encoded, len(encoded) + except ( + Image.DecompressionBombError, + UnidentifiedImageError, + OSError, + ValueError, + ) as exc: + logger.warning("Avatar image could not be decoded: %s", exc) + return None + + +def _write_atomic(avatars_dir: str | Path, filename: str, body: bytes) -> bool: + """Write *body* to *filename* atomically inside *avatars_dir*.""" + avatars_path = Path(avatars_dir) + tmp_path = avatars_path / f"{filename}.tmp" + final_path = avatars_path / filename + + try: + avatars_path.mkdir(parents=True, exist_ok=True) + tmp_path.write_bytes(body) + os.replace(tmp_path, final_path) + except OSError as exc: + logger.error("Failed to write avatar %s: %s", filename, exc) + return False + return True + + +def _store_normalised_avatar( + body: bytes, + avatars_dir: str | Path, + user_id: int, + variant: str, +) -> StoredAvatar | None: + normalised = _normalise_image(body) + if normalised is None: + return None + + encoded, byte_size = normalised + digest = hashlib.sha256(encoded).hexdigest()[:32] + filename = f"{user_id}__{variant}__{digest}.webp" + + if not _write_atomic(avatars_dir, filename, encoded): + return None + + logger.debug("Avatar saved: %s (%d bytes)", filename, byte_size) + return StoredAvatar( + filename=filename, + content_type="image/webp", + byte_size=byte_size, + ) + + +def save_uploaded_avatar( + body: bytes, + content_type: str, + avatars_dir: str | Path, + user_id: int, +) -> StoredAvatar | None: + """Validate and persist an uploaded avatar image.""" + declared = (content_type or "").split(";")[0].strip().lower() + if declared not in _ALLOWED_CONTENT_TYPES: + logger.warning("Uploaded avatar has unsupported content type: %s", declared) + return None + + if len(body) > settings.avatar_max_upload_mb * 1024 * 1024: + logger.warning("Uploaded avatar exceeds size limit: %d bytes", len(body)) + return None + + return _store_normalised_avatar(body, avatars_dir, user_id, "upload") + + +def is_safe_oidc_picture_url( + url: str | None, + trusted_private_host: str | None = None, +) -> bool: + """Return True for an HTTPS OIDC picture URL that is safe to download. + + ``trusted_private_host`` is the OIDC provider's own hostname. Providers + hosted on a LAN resolve to private addresses, which the normal SSRF check + rejects. A picture URL on the provider's own host is allowed because the + provider is already trusted for authentication. + """ + if not url or len(url) > _MAX_PICTURE_URL_LENGTH: + return False + + try: + parsed = urlparse(url) + except ValueError: + return False + + if parsed.scheme != "https" or not parsed.hostname: + return False + if parsed.username or parsed.password or parsed.fragment: + return False + + hostname = parsed.hostname.lower() + if trusted_private_host and hostname == trusted_private_host.lower(): + return True + + return is_safe_cover_import_url(url) + + +async def cache_oidc_avatar( + picture_url: str, + avatars_dir: str | Path, + user_id: int, + timeout: float | None = None, + max_bytes: int | None = None, + trusted_private_host: str | None = None, +) -> StoredAvatar | None: + """Download an OIDC picture and store it as a normalised WebP avatar. + + Any failure returns ``None`` and is logged; callers must never fail an OIDC + login because of an avatar problem. + """ + if not is_safe_oidc_picture_url(picture_url, trusted_private_host): + logger.warning("Refusing unsafe OIDC avatar URL: %s", picture_url) + return None + + limit = max_bytes or settings.avatar_fetch_max_bytes + request_timeout = timeout or settings.avatar_fetch_timeout_seconds + + try: + async with httpx.AsyncClient( + follow_redirects=False, + timeout=request_timeout, + ) as client: + async with client.stream("GET", picture_url) as response: + if response.status_code < 200 or response.status_code >= 300: + logger.warning( + "OIDC avatar download returned %s for %s", + response.status_code, + picture_url, + ) + return None + + declared_length = response.headers.get("content-length") + if declared_length and declared_length.isdigit() and int(declared_length) > limit: + logger.warning( + "OIDC avatar exceeds size limit: %s bytes", + declared_length, + ) + return None + + body = bytearray() + async for chunk in response.aiter_bytes(): + body.extend(chunk) + if len(body) > limit: + logger.warning("OIDC avatar exceeded download limit") + return None + except (httpx.HTTPError, OSError) as exc: + logger.warning("OIDC avatar download failed for %s: %s", picture_url, exc) + return None + + return await run_in_threadpool( + _store_normalised_avatar, + bytes(body), + avatars_dir, + user_id, + "oidc", + ) + + +def cleanup_orphan_avatars(session: Session, grace_minutes: int = 60) -> int: + """Delete avatar files on disk that are no longer referenced by any row.""" + avatars_path = Path(settings.avatars_dir) + if not avatars_path.exists(): + return 0 + + referenced = { + filename + for filename in session.exec(select(UserImage.filename)).all() + if filename + } + + cutoff = time.time() - (grace_minutes * 60) + deleted = 0 + for file_path in avatars_path.iterdir(): + if not file_path.is_file() or file_path.name in referenced: + continue + try: + if file_path.stat().st_mtime > cutoff: + logger.debug("Skipping recently modified avatar: %s", file_path.name) + continue + file_path.unlink() + logger.info("Deleted orphaned avatar: %s", file_path.name) + deleted += 1 + except OSError as exc: + logger.warning("Failed to delete orphaned avatar %s: %s", file_path.name, exc) + + return deleted diff --git a/backend/app/services/backup_restore.py b/backend/app/services/backup_restore.py index 7eb6ef4f..c0e877d2 100644 --- a/backend/app/services/backup_restore.py +++ b/backend/app/services/backup_restore.py @@ -231,14 +231,16 @@ def create_backup( data_dir: str, covers_dir: str, import_temp_dir: str, + avatars_dir: str | None = None, ) -> bytes: - """Create a full backup ZIP containing the database, covers, and import temp files. + """Create a full backup ZIP containing the database, covers, avatars, and temp files. Args: database_url: SQLAlchemy database URL. data_dir: Root data directory. covers_dir: Cover storage directory. import_temp_dir: Import temporary files directory. + avatars_dir: Profile picture storage directory, when configured. Returns: ZIP archive as bytes. @@ -247,11 +249,16 @@ def create_backup( try: covers_path = Path(covers_dir) import_temp_path = Path(import_temp_dir) + avatars_path = Path(avatars_dir) if avatars_dir else None cover_count = 0 if covers_path.is_dir(): cover_count = sum(1 for _ in covers_path.iterdir() if _.is_file()) + avatar_count = 0 + if avatars_path is not None and avatars_path.is_dir(): + avatar_count = sum(1 for _ in avatars_path.iterdir() if _.is_file()) + temp_count = 0 if import_temp_path.is_dir(): temp_count = sum(1 for _ in import_temp_path.rglob("*") if _.is_file()) @@ -263,6 +270,7 @@ def create_backup( "app_version": __version__, "database_size_bytes": db_size, "covers_count": cover_count, + "avatars_count": avatar_count, "import_temp_files_count": temp_count, } @@ -281,6 +289,11 @@ def create_backup( if entry.is_file(): zf.write(str(entry), f"data/covers/{entry.name}") + if avatars_path is not None and avatars_path.is_dir(): + for entry in sorted(avatars_path.iterdir()): + if entry.is_file(): + zf.write(str(entry), f"data/avatars/{entry.name}") + if import_temp_path.is_dir(): for entry in sorted(import_temp_path.rglob("*")): if entry.is_file(): @@ -394,6 +407,7 @@ def restore_backup( data_dir: str, covers_dir: str, import_temp_dir: str, + avatars_dir: str | None = None, ) -> dict[str, Any]: """Restore a backup ZIP archive, with rollback on failure. @@ -403,9 +417,10 @@ def restore_backup( data_dir: Root data directory. covers_dir: Cover storage directory. import_temp_dir: Import temporary files directory. + avatars_dir: Profile picture storage directory, when configured. Returns: - A dict with ``"restored_books"`` and ``"restored_covers"`` counts. + A dict with restored book, cover, and avatar counts. """ _acquire_operation_lock() try: @@ -422,8 +437,12 @@ def restore_backup( data_path = Path(data_dir).resolve() covers_path = Path(covers_dir) import_temp_path = Path(import_temp_dir) + avatars_path = Path(avatars_dir) if avatars_dir else None - for d in [covers_path, import_temp_path]: + directories = [covers_path, import_temp_path] + if avatars_path is not None: + directories.append(avatars_path) + for d in directories: if d.is_dir(): shutil.rmtree(str(d)) d.mkdir(parents=True, exist_ok=True) @@ -448,6 +467,10 @@ def restore_backup( if covers_path.is_dir(): restored_covers = sum(1 for _ in covers_path.iterdir() if _.is_file()) + restored_avatars = 0 + if avatars_path is not None and avatars_path.is_dir(): + restored_avatars = sum(1 for _ in avatars_path.iterdir() if _.is_file()) + backup_db = zf.read("database.db") engine.dispose() @@ -484,6 +507,7 @@ def restore_backup( return { "restored_books": restored_books, "restored_covers": restored_covers, + "restored_avatars": restored_avatars, } except Exception as exc: logger.exception("Restore failed, rolling back safety backup") diff --git a/backend/app/services/user_deletion.py b/backend/app/services/user_deletion.py index 1fc87870..2f130d62 100644 --- a/backend/app/services/user_deletion.py +++ b/backend/app/services/user_deletion.py @@ -1,6 +1,6 @@ """User data and account deletion utilities.""" -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Optional from fastapi import HTTPException, status @@ -8,6 +8,7 @@ from app.models import ApiKey, Author, Book, BookAuthor, BookTag, OidcLink, ReadingProgress, ReadingSession, Tag, User, UserRole, UserSettings from app.time_utils import utcnow +from app.services.avatar import delete_all_user_avatar_images from app.services.cover_storage import delete_cover_file, local_cover_filename @@ -19,6 +20,7 @@ class ReadingDataDeletionCounts: authors: int progress_entries: int sessions: int = 0 + avatar_filenames: list[str] = field(default_factory=list) def assert_not_last_admin(session: Session, target_user: User) -> None: @@ -104,6 +106,7 @@ def delete_user_account_data(session: Session, user: User, covers_dir: str) -> R """ assert user.id is not None deletion_counts = delete_user_reading_data(session, user.id, covers_dir) + deletion_counts.avatar_filenames = delete_all_user_avatar_images(session, user.id) for key in session.exec(select(ApiKey).where(ApiKey.user_id == user.id)).all(): key.revoked_at = utcnow() diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 4b409a56..6aa3f2e3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "openpyxl>=3.1.5", "playwright>=1.62.0", "passlib[bcrypt]>=1.7.4", + "pillow>=11.0.0", "pydantic-settings>=2.15.0", "pycountry>=24.6.1", "python-multipart>=0.0.32", diff --git a/backend/scripts/gen_db_docs.py b/backend/scripts/gen_db_docs.py index de62c19b..73ebb9e7 100644 --- a/backend/scripts/gen_db_docs.py +++ b/backend/scripts/gen_db_docs.py @@ -164,7 +164,7 @@ def _generate_table_docs(tables) -> str: lines.append("") for uc in ucs: col_names = ", ".join(c.name for c in uc.columns) - lines.append(f"**Unique constraint:** `({col_names})` — {uc.name}") + lines.append(f"**Unique constraint:** `({col_names})`: {uc.name}") lines.append("") return "\n".join(lines) @@ -212,7 +212,7 @@ def main(): - **Foreign keys** — all user-owned tables reference `user.id` via foreign key constraints. Cascading behavior is handled in application code (not at the database level). -- **Unique constraints** — compound constraints like `(user_id, isbn)` on +- **Unique constraints**: compound constraints like `(user_id, isbn)` on `book` and `(user_id, name)` on `tag` enforce per-user uniqueness without restricting other users. """ diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 83d76e2e..f6621f25 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -13,6 +13,8 @@ # OIDC frontend redirects stay relative in tests (single-origin assumption); # local dev sets PUBLIC_APP_URL in .env for split-origin setups. os.environ.setdefault("PUBLIC_APP_URL", "") +# OIDC callback URIs follow the request in tests; local dev may pin a base URL. +os.environ.setdefault("OIDC_CALLBACK_BASE_URL", "") # Telemetry must never fire during the test suite (no network, no dev-DB writes). os.environ["TELEMETRY_DISABLED"] = "true" @@ -22,6 +24,12 @@ from app.database import get_session +@pytest.fixture(autouse=True) +def isolate_avatar_dir(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep avatar files out of the developer's real data directory.""" + monkeypatch.setattr("app.config.settings.avatars_dir", str(tmp_path / "avatars")) + + @pytest.fixture(name="session") def session_fixture() -> Generator[Session, None, None]: """Provide an in-memory SQLite session, isolated per test.""" diff --git a/backend/tests/test_avatars.py b/backend/tests/test_avatars.py new file mode 100644 index 00000000..07f4e506 --- /dev/null +++ b/backend/tests/test_avatars.py @@ -0,0 +1,338 @@ +"""Tests for profile picture storage and the avatar APIs.""" + +import io +import os +import time +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from PIL import Image +from sqlmodel import Session, select + +from app.config import settings +from app.models import AvatarSource, User, UserImage +from app.services.avatar_storage import cleanup_orphan_avatars + + +def _png_bytes(size: tuple[int, int] = (640, 480)) -> bytes: + image = Image.new("RGB", size, (40, 90, 140)) + output = io.BytesIO() + image.save(output, format="PNG") + return output.getvalue() + + +def _upload(client: TestClient, body: bytes | None = None, content_type: str = "image/png"): + return client.post( + "/api/profile/avatar/upload", + files={"file": ("avatar.png", body or _png_bytes(), content_type)}, + ) + + +def test_avatar_options_default_to_animal(client: TestClient) -> None: + response = client.get("/api/profile/avatar") + assert response.status_code == 200 + body = response.json() + assert body["source"] == "animal" + assert body["avatar_url"] is None + assert body["has_uploaded_avatar"] is False + assert body["has_oidc_avatar"] is False + + +def test_avatar_upload_switch_and_remove(client: TestClient) -> None: + upload = _upload(client) + assert upload.status_code == 200 + options = upload.json() + assert options["source"] == "upload" + assert options["has_uploaded_avatar"] is True + assert options["avatar_url"] + + profile = client.get("/api/profile").json() + assert profile["avatar_url"] == options["avatar_url"] + + served = client.get(options["avatar_url"]) + assert served.status_code == 200 + assert served.headers["content-type"] == "image/webp" + assert served.headers["x-content-type-options"] == "nosniff" + + animal = client.patch("/api/profile/avatar", json={"source": "animal"}) + assert animal.status_code == 200 + assert animal.json()["source"] == "animal" + assert animal.json()["avatar_url"] is None + + upload_again = client.patch("/api/profile/avatar", json={"source": "upload"}) + assert upload_again.status_code == 200 + assert upload_again.json()["source"] == "upload" + + removed = client.delete("/api/profile/avatar/upload") + assert removed.status_code == 200 + assert removed.json()["source"] == "animal" + assert removed.json()["has_uploaded_avatar"] is False + + +def test_avatar_source_requires_available_image(client: TestClient) -> None: + assert client.patch("/api/profile/avatar", json={"source": "upload"}).status_code == 400 + assert client.patch("/api/profile/avatar", json={"source": "oidc"}).status_code == 400 + assert client.patch("/api/profile/avatar", json={"source": "bogus"}).status_code == 422 + + +def test_avatar_upload_rejects_invalid_images(client: TestClient) -> None: + assert _upload(client, b"not an image").status_code == 422 + assert _upload(client, _png_bytes(), content_type="image/svg+xml").status_code == 422 + + +def test_cleanup_orphan_avatars( + client: TestClient, + session: Session, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "avatars_dir", str(tmp_path)) + + referenced = tmp_path / "1__upload__referenced.webp" + orphan = tmp_path / "1__upload__orphan.webp" + recent = tmp_path / "1__upload__recent.webp" + for path in (referenced, orphan, recent): + path.write_bytes(b"data") + + # Make the orphan old enough to be eligible, and keep the recent file fresh. + old = time.time() - 7200 + os.utime(orphan, (old, old)) + + owner = session.exec(select(User)).first() + assert owner is not None and owner.id is not None + session.add( + UserImage( + user_id=owner.id, + kind="avatar", + variant="upload", + filename=referenced.name, + content_type="image/webp", + byte_size=4, + ) + ) + session.commit() + + deleted = cleanup_orphan_avatars(session, grace_minutes=60) + assert deleted == 1 + assert orphan.exists() is False + assert referenced.exists() is True + assert recent.exists() is True + + +def test_public_profile_avatar_section(client: TestClient) -> None: + uploaded = _upload(client) + assert uploaded.status_code == 200 + + created = client.post( + "/api/profile/share-links", + json={ + "name": "Avatar link", + "audience": "public", + "visibility_config": { + "sections": ["username", "user_info", "avatar"], + "statistics": [], + }, + }, + ) + assert created.status_code == 201 + token = created.json()["token"] + + profile = client.get(f"/api/public-profiles/{token}", headers={"X-API-Key": ""}) + assert profile.status_code == 200 + assert "/avatar?v=" in profile.json()["owner"]["avatar_url"] + + avatar = client.get(f"/api/public-profiles/{token}/avatar", headers={"X-API-Key": ""}) + assert avatar.status_code == 200 + assert avatar.headers["content-type"] == "image/webp" + + link_id = created.json()["link"]["id"] + updated = client.patch( + f"/api/profile/share-links/{link_id}", + json={ + "visibility_config": { + "sections": ["username", "user_info"], + "statistics": [], + } + }, + ) + assert updated.status_code == 200 + + hidden_profile = client.get(f"/api/public-profiles/{token}", headers={"X-API-Key": ""}) + assert hidden_profile.json()["owner"]["avatar_url"] is None + hidden_avatar = client.get(f"/api/public-profiles/{token}/avatar", headers={"X-API-Key": ""}) + assert hidden_avatar.status_code == 404 + + +def test_oidc_picture_url_validation() -> None: + from app.services.avatar_storage import is_safe_oidc_picture_url + + assert is_safe_oidc_picture_url("http://example.com/avatar.png") is False + assert is_safe_oidc_picture_url("ftp://example.com/avatar.png") is False + assert is_safe_oidc_picture_url("https://user:pass@example.com/avatar.png") is False + assert is_safe_oidc_picture_url("https://example.com/avatar.png#fragment") is False + assert is_safe_oidc_picture_url("https://example.com/" + "a" * 3000) is False + assert is_safe_oidc_picture_url("https://127.0.0.1/avatar.png") is False + assert is_safe_oidc_picture_url("https://localhost/avatar.png") is False + + +def test_delete_all_user_avatar_images( + client: TestClient, + session: Session, +) -> None: + from app.services.avatar_storage import delete_avatar_file + from app.services.avatar import delete_all_user_avatar_images + + uploaded = _upload(client) + assert uploaded.status_code == 200 + me = client.get("/api/auth/me").json() + user_id = me["id"] + + image = session.exec( + select(UserImage).where(UserImage.user_id == user_id, UserImage.variant == "upload") + ).first() + assert image is not None + filename = image.filename + assert filename is not None + + removed = delete_all_user_avatar_images(session, user_id) + session.commit() + assert removed == [filename] + assert session.exec(select(UserImage).where(UserImage.user_id == user_id)).first() is None + delete_avatar_file(filename, settings.avatars_dir) + + +def test_load_avatar_urls_returns_one_url_per_user( + client: TestClient, + session: Session, +) -> None: + from app.services.avatar import load_avatar_urls + + assert _upload(client).status_code == 200 + me = client.get("/api/auth/me").json() + user = session.get(User, me["id"]) + assert user is not None + urls = load_avatar_urls(session, [user]) + assert set(urls) == {user.id} + assert urls[user.id] is not None + + +def test_upload_size_limit( + client: TestClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "avatar_max_upload_mb", 0) + response = _upload(client) + assert response.status_code == 413 + + +def test_authenticated_only_public_avatar_requires_login(client: TestClient) -> None: + assert _upload(client).status_code == 200 + created = client.post( + "/api/profile/share-links", + json={ + "name": "Private avatar", + "audience": "authenticated", + "visibility_config": {"sections": ["avatar"], "statistics": []}, + }, + ) + assert created.status_code == 201 + token = created.json()["token"] + + anonymous = client.get( + f"/api/public-profiles/{token}/avatar", + headers={"X-API-Key": ""}, + ) + assert anonymous.status_code == 401 + + +def test_oidc_unlink_falls_back_to_upload( + client: TestClient, + session: Session, +) -> None: + from app.models import OidcLink + + assert _upload(client).status_code == 200 + me = client.get("/api/auth/me").json() + user = session.get(User, me["id"]) + assert user is not None and user.id is not None + user.avatar_source = AvatarSource.oidc + session.add( + UserImage( + user_id=user.id, + kind="avatar", + variant="oidc", + filename="missing-oidc.webp", + content_type="image/webp", + ) + ) + session.add( + OidcLink(user_id=user.id, provider_id="test", oidc_sub="unlink-sub") + ) + session.commit() + + response = client.delete("/api/oidc/unlink") + assert response.status_code == 204 + + session.refresh(user) + assert user.avatar_source == AvatarSource.upload + + +def test_authenticated_avatar_endpoint_denies_other_users( + client: TestClient, + create_user_with_key, +) -> None: + assert _upload(client).status_code == 200 + me = client.get("/api/auth/me").json() + _, other_key = create_user_with_key(email="other@example.com") + + response = client.get( + f"/api/users/{me['id']}/avatar", + headers={"X-API-Key": other_key}, + ) + assert response.status_code == 404 + + +def test_authenticated_only_public_avatar_serves_logged_in_viewer(client: TestClient) -> None: + assert _upload(client).status_code == 200 + created = client.post( + "/api/profile/share-links", + json={ + "name": "Private avatar viewer", + "audience": "authenticated", + "visibility_config": {"sections": ["avatar"], "statistics": []}, + }, + ) + assert created.status_code == 201 + token = created.json()["token"] + + response = client.get(f"/api/public-profiles/{token}/avatar") + assert response.status_code == 200 + assert response.headers["cache-control"].startswith("private") + + +def test_oidc_picture_url_allows_the_provider_host() -> None: + from app.services.avatar_storage import is_safe_oidc_picture_url + + assert ( + is_safe_oidc_picture_url( + "https://127.0.0.1/profile-picture.png", + trusted_private_host="127.0.0.1", + ) + is True + ) + assert ( + is_safe_oidc_picture_url( + "https://10.0.0.5/profile-picture.png", + trusted_private_host="10.0.0.5", + ) + is True + ) + assert is_safe_oidc_picture_url("https://127.0.0.1/profile-picture.png") is False + assert ( + is_safe_oidc_picture_url( + "https://10.0.0.5/profile-picture.png", + trusted_private_host="id.example.com", + ) + is False + ) diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index 7b852c6c..0ace0370 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -40,6 +40,8 @@ def test_get_config_returns_feature_flags(client: TestClient, monkeypatch) -> No "thalia_cover_search_enabled": True, "password_login_enabled": False, "trusted_proxy_auth_enabled": True, + "avatar_max_upload_mb": 10, + "avatar_max_dimension": 512, } assert "trusted_proxy_auth_header" not in data diff --git a/backend/tests/test_oidc.py b/backend/tests/test_oidc.py index e3f9f2fd..9b6bbb8c 100644 --- a/backend/tests/test_oidc.py +++ b/backend/tests/test_oidc.py @@ -9,7 +9,8 @@ from unittest.mock import patch from app.config import settings -from app.models import OidcLink, User +from app.models import AvatarSource, OidcLink, User, UserImage +from app.services.avatar_storage import StoredAvatar class FakeOidcClient: @@ -380,6 +381,61 @@ def test_oidc_login_with_x_forwarded_proto(client: TestClient, monkeypatch: Monk assert fake.last_redirect_uri == "https://proxy.example/api/oidc/callback" +def test_oidc_login_uses_configured_callback_base_url( + client: TestClient, + monkeypatch: MonkeyPatch, +) -> None: + """A configured callback base replaces the host the backend sees.""" + _set_oidc_enabled(monkeypatch) + monkeypatch.setattr(settings, "oidc_callback_base_url", "http://dev.example.lan:5173/") + fake = FakeOidcClient() + monkeypatch.setattr("app.routers.oidc.get_oidc_client", lambda: fake) + + response = client.get( + "/api/oidc/login", + headers={"Host": "localhost:8000"}, + follow_redirects=False, + ) + + assert response.status_code == 302 + assert fake.last_redirect_uri == "http://dev.example.lan:5173/api/oidc/callback" + + +def test_oidc_login_ignores_invalid_callback_base_url( + client: TestClient, + monkeypatch: MonkeyPatch, +) -> None: + """A value without scheme and host falls back to the request host.""" + _set_oidc_enabled(monkeypatch) + monkeypatch.setattr(settings, "oidc_callback_base_url", "dev.example.lan:5173") + fake = FakeOidcClient() + monkeypatch.setattr("app.routers.oidc.get_oidc_client", lambda: fake) + + response = client.get("/api/oidc/login", follow_redirects=False) + + assert response.status_code == 302 + assert fake.last_redirect_uri == "http://testserver/api/oidc/callback" + + +def test_oidc_link_authorize_uses_configured_callback_base_url( + client: TestClient, + monkeypatch: MonkeyPatch, +) -> None: + """Account linking uses the same configured callback base as login.""" + _set_oidc_enabled(monkeypatch) + monkeypatch.setattr(settings, "oidc_callback_base_url", "https://books.example.com") + fake = FakeOidcClient() + monkeypatch.setattr("app.routers.oidc.get_oidc_client", lambda: fake) + + start = client.post("/api/oidc/link") + assert start.status_code == 200 + + authorize = client.get("/api/oidc/link/authorize", follow_redirects=False) + + assert authorize.status_code == 302 + assert fake.last_redirect_uri == "https://books.example.com/api/oidc/link-callback" + + def test_oidc_login_authorize_redirect_exception(client: TestClient, monkeypatch: MonkeyPatch) -> None: _set_oidc_enabled(monkeypatch) fake = FakeOidcClientRedirectError() @@ -654,6 +710,33 @@ def test_loopback_public_app_url_warns(monkeypatch: MonkeyPatch) -> None: assert "loopback" in str(mock_warning.call_args) +def test_invalid_callback_base_url_warns(monkeypatch: MonkeyPatch) -> None: + """Startup should explain that a malformed OIDC_CALLBACK_BASE_URL is ignored.""" + from app.routers import oidc as oidc_router + + monkeypatch.setattr(settings, "oidc_callback_base_url", "dev.example.lan:5173") + with patch("app.routers.oidc.logger.warning") as mock_warning: + oidc_router.warn_about_invalid_oidc_callback_base_url() + + mock_warning.assert_called_once() + assert "OIDC_CALLBACK_BASE_URL" in str(mock_warning.call_args) + + +def test_callback_base_url_warning_is_skipped_when_valid_or_unset( + monkeypatch: MonkeyPatch, +) -> None: + """No warning for an empty or well-formed callback base.""" + from app.routers import oidc as oidc_router + + with patch("app.routers.oidc.logger.warning") as mock_warning: + monkeypatch.setattr(settings, "oidc_callback_base_url", "") + oidc_router.warn_about_invalid_oidc_callback_base_url() + monkeypatch.setattr(settings, "oidc_callback_base_url", "https://books.example.com") + oidc_router.warn_about_invalid_oidc_callback_base_url() + + mock_warning.assert_not_called() + + def test_loopback_public_app_url_warning_is_skipped_when_unused( monkeypatch: MonkeyPatch, ) -> None: @@ -667,3 +750,189 @@ def test_loopback_public_app_url_warning_is_skipped_when_unused( oidc_router.warn_about_loopback_public_app_url() mock_warning.assert_not_called() + + +def test_oidc_login_refreshes_cached_picture( + client: TestClient, + session: Session, + monkeypatch: MonkeyPatch, +) -> None: + """A successful OIDC login refreshes the cached picture when selected.""" + _set_oidc_enabled(monkeypatch) + fake = FakeOidcClient( + token={ + "userinfo": { + "sub": "sub-picture", + "picture": "https://issuer.example/picture.png", + } + } + ) + monkeypatch.setattr("app.routers.oidc.get_oidc_client", lambda: fake) + monkeypatch.setattr("app.routers.oidc.is_safe_oidc_picture_url", lambda *_args: True) + + me = client.get("/api/auth/me") + user_id = me.json()["id"] + user = session.get(User, user_id) + assert user is not None + user.avatar_source = AvatarSource.oidc + session.add( + OidcLink( + user_id=user_id, + provider_id="test-oidc", + oidc_sub="sub-picture", + ) + ) + session.commit() + + async def fake_cache(*_args: Any, **_kwargs: Any) -> StoredAvatar: + return StoredAvatar(filename="1__oidc__abc.webp", content_type="image/webp", byte_size=42) + + monkeypatch.setattr("app.routers.oidc.cache_oidc_avatar", fake_cache) + + response = client.get("/api/oidc/callback?code=abc", follow_redirects=False) + assert response.status_code == 302 + assert response.headers["location"] == "/auth/oidc/callback" + + image = session.exec( + select(UserImage).where(UserImage.user_id == user_id, UserImage.variant == "oidc") + ).first() + assert image is not None + assert image.filename == "1__oidc__abc.webp" + assert image.external_url == "https://issuer.example/picture.png" + + +def test_oidc_link_caches_picture_without_switching_source( + client: TestClient, + session: Session, + monkeypatch: MonkeyPatch, +) -> None: + """Linking caches the provider picture but leaves the selected avatar alone.""" + _set_oidc_enabled(monkeypatch) + fake = FakeOidcClient( + token={ + "userinfo": { + "sub": "sub-link-picture", + "picture": "https://issuer.example/link.png", + } + } + ) + monkeypatch.setattr("app.routers.oidc.get_oidc_client", lambda: fake) + monkeypatch.setattr("app.routers.oidc.is_safe_oidc_picture_url", lambda *_args: True) + + me = client.get("/api/auth/me") + user_id = me.json()["id"] + + async def fake_cache(*_args: Any, **_kwargs: Any) -> StoredAvatar: + return StoredAvatar(filename="1__oidc__link.webp", content_type="image/webp", byte_size=42) + + monkeypatch.setattr("app.routers.oidc.cache_oidc_avatar", fake_cache) + + start = client.post("/api/oidc/link") + assert start.status_code == 200 + callback = client.get("/api/oidc/link-callback?code=abc", follow_redirects=False) + assert callback.status_code == 302 + + image = session.exec( + select(UserImage).where(UserImage.user_id == user_id, UserImage.variant == "oidc") + ).first() + assert image is not None + assert image.external_url == "https://issuer.example/link.png" + + user = session.get(User, user_id) + assert user is not None + assert user.avatar_source == AvatarSource.animal + + +def test_oidc_login_caches_picture_for_existing_animal_avatar( + client: TestClient, + session: Session, + monkeypatch: MonkeyPatch, +) -> None: + """Users on the animal avatar get the OIDC picture cached on next login.""" + _set_oidc_enabled(monkeypatch) + fake = FakeOidcClient( + token={ + "userinfo": { + "sub": "sub-animal-picture", + "picture": "https://issuer.example/animal.png", + } + } + ) + monkeypatch.setattr("app.routers.oidc.get_oidc_client", lambda: fake) + monkeypatch.setattr("app.routers.oidc.is_safe_oidc_picture_url", lambda *_args: True) + + me = client.get("/api/auth/me") + user_id = me.json()["id"] + session.add( + OidcLink( + user_id=user_id, + provider_id="test-oidc", + oidc_sub="sub-animal-picture", + ) + ) + session.commit() + + async def fake_cache(*_args: Any, **_kwargs: Any) -> StoredAvatar: + from pathlib import Path + + avatars_dir = Path(settings.avatars_dir) + avatars_dir.mkdir(parents=True, exist_ok=True) + (avatars_dir / "1__oidc__animal.webp").write_bytes(b"data") + return StoredAvatar(filename="1__oidc__animal.webp", content_type="image/webp", byte_size=4) + + monkeypatch.setattr("app.routers.oidc.cache_oidc_avatar", fake_cache) + + response = client.get("/api/oidc/callback?code=abc", follow_redirects=False) + assert response.status_code == 302 + + options = client.get("/api/profile/avatar").json() + assert options["has_oidc_avatar"] is True + assert options["source"] == "animal" + + +def test_oidc_login_accepts_picture_on_provider_host( + client: TestClient, + session: Session, + monkeypatch: MonkeyPatch, +) -> None: + """A picture on the provider's own private host is trusted.""" + _set_oidc_enabled(monkeypatch) + monkeypatch.setattr("app.routers.oidc.oidc_provider_host", lambda: "127.0.0.1") + fake = FakeOidcClient( + token={ + "userinfo": { + "sub": "sub-provider-host", + "picture": "https://127.0.0.1/picture.png", + } + } + ) + monkeypatch.setattr("app.routers.oidc.get_oidc_client", lambda: fake) + + me = client.get("/api/auth/me") + user_id = me.json()["id"] + session.add( + OidcLink( + user_id=user_id, + provider_id="test-oidc", + oidc_sub="sub-provider-host", + ) + ) + session.commit() + + seen: dict[str, Any] = {} + + async def fake_cache(*_args: Any, **kwargs: Any) -> StoredAvatar: + seen.update(kwargs) + return StoredAvatar(filename="1__oidc__host.webp", content_type="image/webp", byte_size=42) + + monkeypatch.setattr("app.routers.oidc.cache_oidc_avatar", fake_cache) + + response = client.get("/api/oidc/callback?code=abc", follow_redirects=False) + assert response.status_code == 302 + assert seen["trusted_private_host"] == "127.0.0.1" + + image = session.exec( + select(UserImage).where(UserImage.user_id == user_id, UserImage.variant == "oidc") + ).first() + assert image is not None + assert image.external_url == "https://127.0.0.1/picture.png" diff --git a/backend/tests/test_public_profile.py b/backend/tests/test_public_profile.py index da836fab..7cd4cf33 100644 --- a/backend/tests/test_public_profile.py +++ b/backend/tests/test_public_profile.py @@ -129,7 +129,7 @@ def test_public_profile_success(client: Any) -> None: resp = _public_profile_with_client(client, token, x_api_key=None) assert resp.status_code == 200 body = resp.json() - assert body["owner"] == {"firstname": "Test", "lastname": "User"} + assert body["owner"] == {"firstname": "Test", "lastname": "User", "avatar_url": None} assert body["audience"] == "public" assert body["visibility_config"]["sections"] == ["username", "currently_reading", "statistics"] assert len(body["books"]) == 2 @@ -381,7 +381,7 @@ def test_public_profile_redacts_owner_name_when_hidden(client: Any) -> None: resp = _public_profile_with_client(client, token, x_api_key=None) assert resp.status_code == 200 owner = resp.json()["owner"] - assert owner == {"firstname": None, "lastname": None} + assert owner == {"firstname": None, "lastname": None, "avatar_url": None} def test_public_profile_sets_security_headers(client: Any) -> None: diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index a653bd96..d59d73a8 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -127,6 +127,7 @@ controlled by the proxy, not by LibrisLog. | `OIDC_CLIENT_ID` | OIDC client ID | | `OIDC_CLIENT_SECRET` | OIDC client secret | | `OIDC_WELL_KNOWN_URL` | OIDC well-known configuration URL | +| `OIDC_CALLBACK_BASE_URL` | Public origin the browser uses for the OIDC callback routes, for example `https://books.example.com`, without a path. Set it when the backend sees a different host than the browser, such as behind the Vite dev proxy, which rewrites `Host` to `localhost`. Leave it empty (the default) to build the callback URI from the request. | ## Password Reset / Email (Optional) @@ -209,6 +210,17 @@ on-demand **Search Google Books too** button. > `LIBRARY_OF_CONGRESS_IMPORT_SEARCH_ENABLED=false` if unencrypted egress to port 210 > is not acceptable in your environment. The other sources are all HTTPS. +## Profile Pictures + +| Variable | Description | Default | +|----------|-------------|---------| +| `AVATARS_DIR` | Directory for uploaded and cached OIDC profile pictures. Keep it under `DATA_DIR` so full backups include it. | `./data/avatars` | +| `AVATAR_MAX_UPLOAD_MB` | Maximum size of an uploaded avatar image | `10` | +| `AVATAR_MAX_DIMENSION` | Maximum width and height of a stored avatar, in pixels | `512` | +| `AVATAR_QUALITY` | WebP quality used when normalising avatars (1-100) | `90` | +| `AVATAR_FETCH_TIMEOUT_SECONDS` | Timeout when downloading an OIDC picture | `10` | +| `AVATAR_FETCH_MAX_BYTES` | Maximum size of a downloaded OIDC picture | `20000000` | + ## Cover Scraping | Variable | Description | Default | @@ -289,6 +301,12 @@ GOOGLE_BOOKS_API_KEY=your-google-books-api-key HARDCOVER_APP_API_TOKEN=your-hardcover-token COVERS_DIR=./data/covers +AVATARS_DIR=./data/avatars +AVATAR_MAX_UPLOAD_MB=10 +AVATAR_MAX_DIMENSION=512 +AVATAR_QUALITY=90 +AVATAR_FETCH_TIMEOUT_SECONDS=10 +AVATAR_FETCH_MAX_BYTES=20000000 THALIA_COVER_SEARCH_ENABLED=false DASHBOARD_QUOTE_ENABLED=true diff --git a/docs/guide/database-layout.md b/docs/guide/database-layout.md index ca9b3ade..7f450a9c 100644 --- a/docs/guide/database-layout.md +++ b/docs/guide/database-layout.md @@ -1,6 +1,6 @@ # Database Layout -> _Auto-generated from SQLModel metadata on 2026-08-24._ +> _Auto-generated from SQLModel metadata on 2026-10-30._ This page documents the LibrisLog database schema. It is intended for developers who need to understand the data model, write queries, or extend @@ -15,7 +15,9 @@ erDiagram user ||--|{ embed_token : "1:N" user ||--|{ import_mapping : "1:N" user ||--|| oidclink : "1:1" + user ||--|{ public_profile_link : "1:N" user ||--|{ tag : "1:N" + user ||--|{ user_image : "1:N" user ||--|| usersettings : "1:1" book ||--|{ book_author : "1:N" author ||--|{ book_author : "1:N" @@ -23,6 +25,15 @@ erDiagram tag ||--|{ book_tag : "1:N" book ||--|{ reading_progress : "1:N" user ||--|{ reading_progress : "1:N" + book ||--|{ reading_session : "1:N" + user ||--|{ reading_session : "1:N" + + installation_info { + integer id PK + varchar(64) installation_id + datetime created_at + datetime updated_at + } user { integer id PK @@ -30,6 +41,7 @@ erDiagram varchar lastname varchar email UK varchar role + varchar avatar_source varchar hashed_password integer credentials_version datetime created_at @@ -112,6 +124,21 @@ erDiagram datetime linked_at } + public_profile_link { + integer id PK + integer user_id + varchar(255) name + varchar token_prefix + varchar token + varchar token_hash UK + varchar audience + varchar language + text visibility_config_json + datetime expires_at + datetime created_at + datetime revoked_at + } + tag { integer id PK integer user_id @@ -119,6 +146,19 @@ erDiagram datetime created_at } + user_image { + integer id PK + integer user_id + varchar kind + varchar variant + varchar filename + varchar external_url + varchar content_type + integer byte_size + datetime created_at + datetime updated_at + } + usersettings { integer id PK integer user_id UK @@ -126,6 +166,20 @@ erDiagram varchar(64) timezone varchar(20) theme varchar(30) custom_theme + boolean goal_pages_per_day_enabled + integer goal_pages_per_day + boolean goal_pages_per_month_enabled + integer goal_pages_per_month + boolean goal_books_per_month_enabled + integer goal_books_per_month + boolean goal_books_per_year_enabled + integer goal_books_per_year + boolean gamification_enabled + boolean auto_set_date_started + boolean auto_set_date_finished + varchar(20) statistics_range + date statistics_custom_from + date statistics_custom_to } book_author { @@ -142,15 +196,43 @@ erDiagram integer id PK integer book_id integer user_id + integer session_id integer page datetime created_at datetime updated_at } + reading_session { + integer id PK + integer book_id + integer user_id + varchar status + datetime date_started + datetime date_finished + boolean is_current + datetime created_at + datetime updated_at + } + ``` ## Tables +### `installation_info` + +Singleton row storing the anonymous installation id for telemetry. + +The id is a random UUIDv4 generated on first startup and persisted in the +database so it stays stable across container restarts and updates. It is +never derived from any machine-specific identifier. + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `id` | `INTEGER` | PK | Auto-increment; default 1 | +| `installation_id` | `VARCHAR(64)` | NOT NULL | | +| `created_at` | `DATETIME` | | UTC | +| `updated_at` | `DATETIME` | | UTC | + ### `user` A user account. @@ -162,6 +244,7 @@ A user account. | `lastname` | `VARCHAR` | NOT NULL | | | `email` | `VARCHAR` | UNIQUE, NOT NULL | | | `role` | `VARCHAR` | NOT NULL, INDEX | default `user` | +| `avatar_source` | `VARCHAR` | NOT NULL, INDEX | default `animal` | | `hashed_password` | `VARCHAR` | NOT NULL | | | `credentials_version` | `INTEGER` | NOT NULL | default 0 | | `created_at` | `DATETIME` | | UTC | @@ -194,7 +277,7 @@ A user-specific author name that can be associated with books. | `name` | `VARCHAR` | NOT NULL, INDEX | | | `created_at` | `DATETIME` | | UTC | -**Unique constraint:** `(user_id, name)` — uq_author_user_id_name +**Unique constraint:** `(user_id, name)`: uq_author_user_id_name ### `book` @@ -216,13 +299,13 @@ A book in the user's library. | `rating` | `INTEGER` | | ≥ 1; ≤ 5 | | `reading_status` | `VARCHAR` | NOT NULL, INDEX | default `want_to_read` | | `acquisition_status` | `VARCHAR` | NOT NULL, INDEX | default `owned` | -| `medium` | `VARCHAR` | INDEX, nullable | Stored as the enum key (`print`, `ebook`, `audiobook`, `comic_graphic_novel`, or `magazine_newspaper`) | +| `medium` | `VARCHAR` | INDEX | | | `user_id` | `INTEGER` | FK → user.id, INDEX | | | `date_added` | `DATETIME` | INDEX | UTC | | `date_started` | `DATETIME` | INDEX | UTC | | `date_finished` | `DATETIME` | INDEX | UTC | -**Unique constraint:** `(user_id, isbn)` — uq_book_user_id_isbn +**Unique constraint:** `(user_id, isbn)`: uq_book_user_id_isbn ### `embed_token` @@ -257,7 +340,7 @@ A saved column-mapping configuration for data import. | `created_at` | `DATETIME` | | UTC | | `updated_at` | `DATETIME` | | UTC | -**Unique constraint:** `(user_id, name)` — uq_import_mapping_user_id_name +**Unique constraint:** `(user_id, name)`: uq_import_mapping_user_id_name ### `oidclink` @@ -273,6 +356,25 @@ Links an OIDC identity to a local user account. | `oidc_name` | `VARCHAR` | | | | `linked_at` | `DATETIME` | | UTC | +### `public_profile_link` + +A shareable public profile link owned by a user. + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `id` | `INTEGER` | PK | Auto-increment | +| `user_id` | `INTEGER` | FK → user.id, NOT NULL, INDEX | | +| `name` | `VARCHAR(255)` | NOT NULL | | +| `token_prefix` | `VARCHAR` | NOT NULL, INDEX | | +| `token` | `VARCHAR` | | | +| `token_hash` | `VARCHAR` | UNIQUE, NOT NULL | | +| `audience` | `VARCHAR` | NOT NULL | default `public` | +| `language` | `VARCHAR` | | | +| `visibility_config_json` | `TEXT` | | default `{}` | +| `expires_at` | `DATETIME` | | UTC | +| `created_at` | `DATETIME` | | UTC | +| `revoked_at` | `DATETIME` | | UTC | + ### `tag` A user-specific tag that can be applied to books. @@ -284,7 +386,26 @@ A user-specific tag that can be applied to books. | `name` | `VARCHAR` | NOT NULL, INDEX | | | `created_at` | `DATETIME` | | UTC | -**Unique constraint:** `(user_id, name)` — uq_tag_user_id_name +**Unique constraint:** `(user_id, name)`: uq_tag_user_id_name + +### `user_image` + +A locally cached or uploaded image owned by a user. + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `id` | `INTEGER` | PK | Auto-increment | +| `user_id` | `INTEGER` | FK → user.id, NOT NULL, INDEX | | +| `kind` | `VARCHAR` | NOT NULL, INDEX | default `avatar` | +| `variant` | `VARCHAR` | NOT NULL, INDEX | | +| `filename` | `VARCHAR` | | | +| `external_url` | `VARCHAR` | | | +| `content_type` | `VARCHAR` | | | +| `byte_size` | `INTEGER` | | | +| `created_at` | `DATETIME` | NOT NULL | UTC | +| `updated_at` | `DATETIME` | NOT NULL | UTC | + +**Unique constraint:** `(user_id, kind, variant)`: uq_user_image_user_kind_variant ### `usersettings` @@ -298,8 +419,20 @@ Per-user settings such as language, timezone, and theme. | `timezone` | `VARCHAR(64)` | NOT NULL | default `UTC` | | `theme` | `VARCHAR(20)` | NOT NULL | default `light` | | `custom_theme` | `VARCHAR(30)` | | | -| `auto_set_date_started` | `BOOLEAN` | NOT NULL | default `true` | -| `auto_set_date_finished` | `BOOLEAN` | NOT NULL | default `true` | +| `goal_pages_per_day_enabled` | `BOOLEAN` | NOT NULL | default False | +| `goal_pages_per_day` | `INTEGER` | NOT NULL | default 20; ≥ 1 | +| `goal_pages_per_month_enabled` | `BOOLEAN` | NOT NULL | default False | +| `goal_pages_per_month` | `INTEGER` | NOT NULL | default 300; ≥ 1 | +| `goal_books_per_month_enabled` | `BOOLEAN` | NOT NULL | default False | +| `goal_books_per_month` | `INTEGER` | NOT NULL | default 2; ≥ 1 | +| `goal_books_per_year_enabled` | `BOOLEAN` | NOT NULL | default False | +| `goal_books_per_year` | `INTEGER` | NOT NULL | default 25; ≥ 1 | +| `gamification_enabled` | `BOOLEAN` | NOT NULL | default True | +| `auto_set_date_started` | `BOOLEAN` | NOT NULL | default True | +| `auto_set_date_finished` | `BOOLEAN` | NOT NULL | default True | +| `statistics_range` | `VARCHAR(20)` | NOT NULL | default `alltime` | +| `statistics_custom_from` | `DATE` | | | +| `statistics_custom_to` | `DATE` | | | ### `book_author` @@ -328,10 +461,35 @@ A page-number reading progress entry for a book. | `id` | `INTEGER` | PK | Auto-increment | | `book_id` | `INTEGER` | FK → book.id, NOT NULL, INDEX | | | `user_id` | `INTEGER` | FK → user.id, NOT NULL, INDEX | | +| `session_id` | `INTEGER` | INDEX | | | `page` | `INTEGER` | NOT NULL | ≥ 0 | | `created_at` | `DATETIME` | | UTC | | `updated_at` | `DATETIME` | | UTC | +### `reading_session` + +A single reading period (first read, re-read, etc.) of a book. + +``Book.date_started`` / ``Book.date_finished`` remain a denormalized mirror +of the *current* session (``is_current=True``), or of the latest session when +none is current, so every existing consumer keeps working unchanged. A book +with a single session therefore behaves identically to the pre-session model. + +At most one current session exists per book (enforced by a partial unique +index on ``is_current`` and by the service layer). + +| Column | Type | Constraints | Notes | +|--------|------|-------------|-------| +| `id` | `INTEGER` | PK | Auto-increment | +| `book_id` | `INTEGER` | FK → book.id, NOT NULL, INDEX | | +| `user_id` | `INTEGER` | FK → user.id, NOT NULL, INDEX | | +| `status` | `VARCHAR` | NOT NULL, INDEX | | +| `date_started` | `DATETIME` | INDEX | UTC | +| `date_finished` | `DATETIME` | INDEX | UTC | +| `is_current` | `BOOLEAN` | NOT NULL, INDEX | default False | +| `created_at` | `DATETIME` | | UTC | +| `updated_at` | `DATETIME` | | UTC | + ## Enums @@ -344,6 +502,31 @@ A page-number reading progress entry for a book. | `digital_access` | Digital Access | | `to_acquire` | To Acquire | +### `AvatarSource` + +| Value | Meaning | +|-------|---------| +| `animal` | Animal | +| `oidc` | Oidc | +| `upload` | Upload | + +### `Medium` + +| Value | Meaning | +|-------|---------| +| `Print` | Print | +| `eBook` | Ebook | +| `Audiobook` | Audiobook | +| `Comic / Graphic Novel` | Comic / Graphic Novel | +| `Magazine / Newspaper` | Magazine / Newspaper | + +### `PublicProfileAudience` + +| Value | Meaning | +|-------|---------| +| `public` | Public | +| `authenticated` | Authenticated | + ### `ReadingStatus` | Value | Meaning | @@ -371,6 +554,6 @@ A page-number reading progress entry for a book. - **Foreign keys** — all user-owned tables reference `user.id` via foreign key constraints. Cascading behavior is handled in application code (not at the database level). -- **Unique constraints** — compound constraints like `(user_id, isbn)` on +- **Unique constraints**: compound constraints like `(user_id, isbn)` on `book` and `(user_id, name)` on `tag` enforce per-user uniqueness without restricting other users. diff --git a/docs/guide/using-librislog/administration.md b/docs/guide/using-librislog/administration.md index 707f03a2..51b8bce2 100644 --- a/docs/guide/using-librislog/administration.md +++ b/docs/guide/using-librislog/administration.md @@ -79,7 +79,7 @@ Things to keep in mind: ### Creating a Backup -Downloads a ZIP archive containing the SQLite database, cover images, and import temp files. This is a complete snapshot of your library, users, and settings. +Downloads a ZIP archive containing the SQLite database, cover images, profile pictures, and import temp files. This is a complete snapshot of your library, users, and settings. ### Restoring a Backup @@ -116,10 +116,11 @@ Crontab entry (daily at 3am): 0 3 * * * /path/to/backup-script.sh ``` -The API returns a ZIP archive containing the SQLite database, cover images, and import temp files. No separate database dump step is needed. +The API returns a ZIP archive containing the SQLite database, cover images, profile pictures, and import temp files. No separate database dump step is needed. ## Background Maintenance A periodic maintenance task runs automatically every hour. It performs: - **Cover cache cleanup** — Removes orphaned cover images from disk that are no longer referenced by any book. Files modified within the last 60 minutes are preserved to avoid deleting covers that were just uploaded but not yet linked to a book entry. +- **Avatar cleanup**: Removes orphaned profile picture files from `AVATARS_DIR` that are no longer referenced by a `user_image` row. The same 60-minute grace period protects freshly uploaded files. diff --git a/docs/guide/using-librislog/profile.md b/docs/guide/using-librislog/profile.md index 42a616b2..912942b8 100644 --- a/docs/guide/using-librislog/profile.md +++ b/docs/guide/using-librislog/profile.md @@ -14,6 +14,41 @@ If you have forgotten your password and mail is configured, click the **"Forgot This feature requires SMTP settings to be configured — see [Configuration](/guide/configuration#password-reset-email-optional). ::: +## Profile Picture + +The **Profile Picture** section shows a large preview and three selectable source cards: + +1. **Animal avatar**: the generated avatar used by default. Existing accounts keep this avatar after upgrading. +2. **OIDC picture**: the picture provided by your OIDC provider, cached locally and refreshed automatically at each OIDC login. +3. **Uploaded picture**: an image you upload from your computer. + +Choose a source by selecting its card. The animal avatar is always available. The OIDC and uploaded cards are enabled once the corresponding image exists. The selected card is highlighted, and the preview updates immediately after the source changes. + +### Uploading a picture + +Click **Upload picture** and choose a PNG, JPEG, or WebP file. The cropper opens with a circular mask that matches the round avatar used across LibrisLog. Drag the image to reposition it, use the zoom buttons to change the scale, and resize the circle to frame the crop. **Reset** returns both the image and the mask to their starting position. + +Click **Save picture** to upload the result. The server validates the image, resizes it to at most 512 pixels, strips metadata, and stores it as WebP. Uploaded pictures are stored under `AVATARS_DIR`. Use **Remove uploaded picture** to delete the file and fall back to the OIDC picture or the animal avatar. + +### OIDC pictures + +When you link an OIDC account, LibrisLog caches the provider's `picture` claim if present. While the OIDC picture is the selected avatar, it is refreshed automatically on every OIDC login. If the provider temporarily omits the picture, the cached image is kept. Use **Refresh OIDC picture** to fetch it again without signing out. + +Unlinking OIDC removes the cached OIDC picture and falls back to your uploaded picture, or to the animal avatar when no upload exists. + +### Profile picture settings + +| Setting | Purpose | +|---|---| +| `AVATARS_DIR` | Directory for uploaded and cached OIDC pictures. Keep it under `DATA_DIR` so full backups include it. | +| `AVATAR_MAX_UPLOAD_MB` | Maximum size of an uploaded image. | +| `AVATAR_MAX_DIMENSION` | Maximum stored width and height. | +| `AVATAR_QUALITY` | WebP quality used when normalising avatars. | +| `AVATAR_FETCH_TIMEOUT_SECONDS` | Timeout for downloading an OIDC picture. | +| `AVATAR_FETCH_MAX_BYTES` | Maximum size of a downloaded OIDC picture. | + +See [Configuration → Profile Pictures](/guide/configuration#profile-pictures) for defaults and examples. + ## Language Switch the UI language between available locales. The change applies immediately after saving. @@ -70,7 +105,7 @@ Create a read-only public view of your reading profile and share it with a URL. 4. Choose who can access it: - **Everyone** — anyone with the URL can view the profile, without logging in. - **Logged-in users only** — viewers must be signed in to LibrisLog. -5. Under **Content**, select the profile sections to share. Statistics can be enabled separately and configured by group. +5. Under **Content**, select the profile sections to share. **Profile picture** is its own toggle and is off by default for backward compatibility. Statistics can be enabled separately and configured by group. 6. Choose the language for this URL. The public page uses the link's language independently of your viewer's current UI language. 7. Under **Validity**, leave the link unlimited or set an expiration date. 8. Save the link. diff --git a/docs/releases.md b/docs/releases.md index d596e974..80d5846c 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -8,8 +8,8 @@ You can also browse the [GitHub Releases](https://github.com/codebude/librislog/ ## Unreleased -::: tip ⭐ v1.12.0 - Mass Tag Editor & Authentication Methods -LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on many books at once, plus two opt-in authentication options: password login can be disabled once single sign-on is in use, and a trusted reverse proxy can sign users in from a forwarded header. The tag editor change is non-breaking, and the authentication features are off by default and need no data migration. +::: tip ⭐ v1.12.0 - Mass Tag Editor, Profile Pictures & Authentication Methods +LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on many books at once, and profile pictures with three selectable sources: the existing animal avatar, a locally cached OIDC picture, and a manually uploaded image with a built-in cropper. Public profiles gain a separate profile picture toggle. It also adds two opt-in authentication options: password login can be disabled once single sign-on is in use, and a trusted reverse proxy can sign users in from a forwarded header. Existing accounts keep their animal avatar and existing share links stay unchanged after upgrading, and the new options need no manual data migration. ::: ## Latest Release @@ -22,7 +22,7 @@ LibrisLog v1.11.0 lets books be read more than once. Each reading is tracked as | Version | Date | Type | |---|---|---| -| [v1.12.0](#v1-12-0-mass-tag-editor-authentication-methods) | Unreleased | Feature release | +| [v1.12.0](#v1-12-0-mass-tag-editor-profile-pictures-authentication-methods) | Unreleased | Feature release | | [v1.11.0](#v1-11-0-reading-sessions-re-reads) | 2026-10-24 | Feature release | | [v1.10.0](#v1-10-0-more-book-sources-upgrades) | 2026-10-18 | Feature release | | [v1.9.0](#v1-9-0-smarter-import-search-input-ux) | 2026-09-15 | Feature release | @@ -44,17 +44,22 @@ LibrisLog v1.11.0 lets books be read more than once. Each reading is tracked as --- -## v1.12.0: Mass Tag Editor & Authentication Methods +## v1.12.0: Mass Tag Editor, Profile Pictures & Authentication Methods -**Summary:** LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on many books at once, plus two opt-in authentication options. Administrators can disable password login once single sign-on is in use, and deployments behind an authenticating reverse proxy can sign users in automatically from a forwarded header. +**Summary:** LibrisLog v1.12.0 adds a Mass Tag Editor page for adding or removing tags on many books at once, profile pictures with three selectable sources (animal avatar, cached OIDC picture, and manual upload with a circular cropper), a separate profile picture toggle for public profiles, and two opt-in authentication options. Administrators can disable password login once single sign-on is in use, and deployments behind an authenticating reverse proxy can sign users in automatically from a forwarded header. **Features** - 🏷️ **Mass Tag Editor page.** New page at `/tag-editor`, reachable from Profile → Manage my data, with the same filter bar as the library (search, status, medium, acquisition, tag) plus row selection and "select all matching filters". -- 🔁 **Add and remove in one step.** Both fields in a single action, applied in chunks of 500 books with progress reporting. -- ⚠️ **Case-variant warning.** Tags that differ only in capitalization from an existing tag are flagged, with a one-click shortcut to reuse the existing spelling. -- 🧹 **Orphan tag cleanup.** Tags left on no book are deleted automatically. + - 🔁 **Add and remove in one step.** Both fields in a single action, applied in chunks of 500 books with progress reporting. + - ⚠️ **Case-variant warning.** Tags that differ only in capitalization from an existing tag are flagged, with a one-click shortcut to reuse the existing spelling. + - 🧹 **Orphan tag cleanup.** Tags left on no book are deleted automatically. +- 🖼️ **Profile pictures with three sources.** Choose the generated animal avatar, the picture from your OIDC provider, or an uploaded image. Existing accounts keep the animal avatar, so nothing changes until you pick a source. + - ✂️ **Built-in cropper.** Uploaded images open in a round cropper before they are sent to the server. The backend validates the image, resizes it to at most 512 pixels, strips metadata, and stores it as WebP under `AVATARS_DIR`. + - 🏷️ **OIDC pictures refresh on login.** The provider's `picture` claim is cached locally and refreshed on every OIDC login. Linking an account caches the picture without switching the avatar automatically; unlinking removes the cache and falls back to the uploaded picture or the animal avatar. + - 👁️ **Separate public profile picture toggle.** Share links get their own **Profile picture** section. It is off by default, so existing links keep their current appearance. The tokenized avatar endpoint only serves the image when that section is enabled. + - 🧹 **Avatar cleanup.** The hourly maintenance task also removes orphaned avatar files that are no longer referenced by a user image row. - 🔐 **Disable password login.** `PASSWORD_LOGIN_ENABLED=false` hides the email/password form and "Forgot password?" link and makes the login, forgot-password, and reset-password endpoints return `403`. First-admin setup still works, so a new instance can always be bootstrapped. The setup page warns when single sign-on should be linked before logging out. - 🌐 **Trusted reverse-proxy authentication.** `TRUSTED_PROXY_AUTH_ENABLED=true` signs a user in automatically when a trusted proxy forwards an identity in a configurable header. The header, trusted IPs or CIDR ranges, and an optional shared secret are configurable, and `TRUSTED_PROXY_AUTH_USER_MAP` can translate proxy usernames to account emails when the proxy name is not the email itself. The feature fails closed when misconfigured, rejects ambiguous email matches, and ignores unknown values. - 🔁 **Automatic OIDC redirect.** `OIDC_AUTO_REDIRECT=true` sends the login page straight to the provider when `PASSWORD_LOGIN_ENABLED=false`. Failed attempts return to the login page with an error instead of looping. @@ -65,14 +70,17 @@ LibrisLog v1.11.0 lets books be read more than once. Each reading is tracked as - 🔍 **Exact tag filters.** `GET /api/books` and the new `GET /api/books/ids` accept `tag` and `exclude_tag` for exact tag name filtering. - 📋 **Tag listing.** New `GET /api/tags` returns all tags with usage counts. - 📚 **Authentication documentation.** New configuration reference and administration guidance, including a Traefik BasicAuth example and recovery steps for password lockout. +- ⚙️ **Profile picture settings.** `AVATARS_DIR`, `AVATAR_MAX_UPLOAD_MB`, `AVATAR_MAX_DIMENSION`, `AVATAR_QUALITY`, `AVATAR_FETCH_TIMEOUT_SECONDS`, and `AVATAR_FETCH_MAX_BYTES` control avatar storage, upload limits, image normalisation, and OIDC picture downloads. See the [Configuration guide](/guide/configuration#profile-pictures). **Dependencies** - ⬆️ Updated backend and frontend dependencies, including FastAPI, Pydantic, SQLAlchemy, Alembic, Starlette, pytest, Playwright, Scrapling, curl-cffi, SvelteKit, Svelte, Vite, Vitest, and Chart.js. TypeScript 7 was intentionally deferred because it is a major upgrade. +- ➕ Added Pillow for server-side avatar validation and normalisation, and cropperjs for the profile picture cropper. **Bug fixes** - 🔐 **OIDC redirect safety net.** A loopback `PUBLIC_APP_URL` such as the old `.env.example` value `http://localhost:5173` is ignored for non-local requests, so existing installations do not redirect users to localhost after upgrading. A startup warning explains how to set it correctly. +- 🔗 **Configurable OIDC callback origin.** `OIDC_CALLBACK_BASE_URL` lets the backend advertise a different public origin for the OIDC callback routes, which fixes login behind proxies that rewrite the `Host` header, such as the Vite dev server on a custom LAN hostname. Left empty, the callback URI is still built from the request as before. -**Breaking changes:** None. No data migration. Existing endpoints keep their behaviour; only two optional query parameters and new endpoints were added. Both authentication features are disabled by default. Existing installations keep password login and behave exactly as before. +**Breaking changes:** None. The profile picture feature adds an automatic database migration for `user.avatar_source` and the `user_image` table; no manual action is needed. Existing accounts keep the animal avatar, and existing share links keep their current sections because the new **Profile picture** toggle is off by default. Existing endpoints keep their behaviour; only two optional query parameters and new endpoints were added. Both authentication features are disabled by default. Existing installations keep password login and behave exactly as before. [Compare with v1.11.0](https://github.com/codebude/librislog/compare/v1.11.0...HEAD) diff --git a/frontend/e2e/fixtures/avatar.png b/frontend/e2e/fixtures/avatar.png new file mode 100644 index 00000000..733f63f7 Binary files /dev/null and b/frontend/e2e/fixtures/avatar.png differ diff --git a/frontend/e2e/specs/11-profile.spec.ts b/frontend/e2e/specs/11-profile.spec.ts index b7586e68..331d33bf 100644 --- a/frontend/e2e/specs/11-profile.spec.ts +++ b/frontend/e2e/specs/11-profile.spec.ts @@ -155,3 +155,34 @@ test.describe('Profile', () => { await expect(input).toBeEnabled(); }); }); + +test.describe('Profile picture', () => { + test.beforeEach(async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + }); + + test('11.9 avatar section defaults to the animal avatar', async ({ page }) => { + await page.goto('/profile'); + await page.locator('#section-avatar').scrollIntoViewIfNeeded(); + + const sources = page.locator('#section-avatar input[name="avatar-source"]'); + await expect(sources).toHaveCount(3); + await expect(sources.first()).toBeChecked(); + await expect(page.locator('#section-avatar button.btn-primary')).toBeVisible(); + }); + + test('11.10 upload a cropped avatar and show it in the user menu', async ({ page }) => { + await page.goto('/profile'); + await page.locator('#section-avatar').scrollIntoViewIfNeeded(); + + await page.locator('#section-avatar input[type=file]').setInputFiles('e2e/fixtures/avatar.png'); + const dialog = page.locator('.modal.modal-open'); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText(/crop your picture|bild zuschneiden/i); + + await dialog.locator('button.btn-primary').click(); + await expect(page.locator('#section-avatar .alert')).toBeVisible({ timeout: 10000 }); + + await expect(page.locator('button:has(img[src*="/api/users/"])').first()).toBeVisible(); + }); +}); diff --git a/frontend/e2e/specs/15-public-profile.spec.ts b/frontend/e2e/specs/15-public-profile.spec.ts index 0a169b58..67952d05 100644 --- a/frontend/e2e/specs/15-public-profile.spec.ts +++ b/frontend/e2e/specs/15-public-profile.spec.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { test, expect, type Browser, type Page } from '@playwright/test'; import { loginViaUi } from '../fixtures/auth.fixture'; import { SEED_USER } from '../fixtures/seed-data'; @@ -187,4 +188,48 @@ test.describe('Public Profile', () => { await expect(dialog.locator('select[name="share-link-language"]')).toHaveValue('de'); await dialog.locator('button[aria-label="Close"]').click(); }); -}); \ No newline at end of file +}); +test.describe('Public profile picture', () => { + test.beforeEach(async ({ page }) => { + await loginViaUi(page, SEED_USER.email, SEED_USER.password); + await page.goto('/profile'); + await expect(page.locator('#section-share-profile')).toBeVisible(); + }); + + test('15.9 avatar section can be enabled and disabled independently', async ({ page, browser }) => { + // Upload a fixture avatar so this spec does not depend on spec 11.10. + const csrf = await (await page.request.get('/api/auth/csrf')).json(); + await page.request.post('/api/profile/avatar/upload', { + headers: { 'X-CSRF-Token': csrf.csrf_token }, + multipart: { + file: { + name: 'avatar.png', + mimeType: 'image/png', + buffer: readFileSync('e2e/fixtures/avatar.png') + } + } + }); + + const shareUrl = await createShareLink(page, 'Avatar Public Profile', { + sections: ['username', 'avatar'] + }); + + const publicPage = await openIncognito(browser, shareUrl); + await expect(publicPage.locator('main img[src*="/avatar"]').first()).toBeVisible(); + await publicPage.close(); + + const csrf2 = await (await page.request.get('/api/auth/csrf')).json(); + const links = await (await page.request.get('/api/profile/share-links')).json(); + const link = links.find((l: { name: string }) => l.name === 'Avatar Public Profile'); + expect(link).toBeTruthy(); + + await page.request.patch(`/api/profile/share-links/${link.id}`, { + headers: { 'X-CSRF-Token': csrf2.csrf_token, 'Content-Type': 'application/json' }, + data: { visibility_config: { sections: ['username'], statistics: [] } } + }); + + const hiddenPage = await openIncognito(browser, shareUrl); + await expect(hiddenPage.locator('main img[src*="/avatar"]')).toHaveCount(0); + await hiddenPage.close(); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f61f7336..8edae7fe 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "chart.js": "^4.5.1", "chartjs-chart-matrix": "^3.0.4", "chartjs-plugin-zoom": "^2.2.0", + "cropperjs": "^2.3.1", "daisyui": "^5.5.19", "dayjs": "^1.11.20", "hammerjs": "^2.0.8", @@ -135,6 +136,126 @@ "node": ">=18" } }, + "node_modules/@cropper/element": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cropper/element/-/element-2.3.1.tgz", + "integrity": "sha512-CgrLOx0qERWlfJK1Ed6ePq7SXcB6gLHtj3mtDVypyzti70fJdmNxrFm5Ucbq8vpp2Yo3tW2L6uDI0IBDoiT17A==", + "license": "MIT", + "dependencies": { + "@cropper/utils": "^2.3.1" + } + }, + "node_modules/@cropper/element-canvas": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cropper/element-canvas/-/element-canvas-2.3.1.tgz", + "integrity": "sha512-UH3VMyjn58PdwYkPiTHuvRUjGzmyKr6E2pYWxTxN/04wnjgYLA77B5u5c1HhV6vD1ZKSvxJ/i9TkiySR9P1D6A==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.3.1", + "@cropper/utils": "^2.3.1" + } + }, + "node_modules/@cropper/element-crosshair": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cropper/element-crosshair/-/element-crosshair-2.3.1.tgz", + "integrity": "sha512-Vz2heRwr2nd2rZ161vKyE4JwYV3eSodGCGGZq39TR0LICGOOeggLq0R664Af/eFB9PU8+tfJ42lWLoUdxQlVJQ==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.3.1", + "@cropper/utils": "^2.3.1" + } + }, + "node_modules/@cropper/element-grid": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cropper/element-grid/-/element-grid-2.3.1.tgz", + "integrity": "sha512-6LDaUDxH2xdA7biTcqnihPQVHjlT4bXysA403s9rU4jo5f4pPnFHtNbB+FQTYKNz5EKun52WgVtR+6oWbmbxlg==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.3.1", + "@cropper/utils": "^2.3.1" + } + }, + "node_modules/@cropper/element-handle": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cropper/element-handle/-/element-handle-2.3.1.tgz", + "integrity": "sha512-ygLw89PWu9k8SuKW7rjRBpD6ZfmtVr5ayWux9YKHU7PxX0SeL5kbCD+PpOxQB1ycW4UcyH0tHt77EWnsdSoOHw==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.3.1", + "@cropper/utils": "^2.3.1" + } + }, + "node_modules/@cropper/element-image": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cropper/element-image/-/element-image-2.3.1.tgz", + "integrity": "sha512-rPE7OX/BPTw66+DjpN5aOuPb1xSAjQMgAJZXdRgmeJnn0YcDDhfGQm8VwcL8/PMLxtWFcKEM+aUiezbO01j4fg==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.3.1", + "@cropper/element-canvas": "^2.3.1", + "@cropper/utils": "^2.3.1" + } + }, + "node_modules/@cropper/element-selection": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cropper/element-selection/-/element-selection-2.3.1.tgz", + "integrity": "sha512-b8sabaFbHeQYY459uItDIeDY0IisZBO/wF+SYhKvAgI8TExj/f2mNcBZXjPHYCkWel2+1BSUTTNZxy+tR8sDEA==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.3.1", + "@cropper/element-canvas": "^2.3.1", + "@cropper/element-image": "^2.3.1", + "@cropper/utils": "^2.3.1" + } + }, + "node_modules/@cropper/element-shade": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cropper/element-shade/-/element-shade-2.3.1.tgz", + "integrity": "sha512-X5ceZsy7iBxuIRhsL05H9+cPYzxZuvM7/9j5irXIbNsDiNwTedxIHdjhEhPbUYa+FaNn9wsKId24AEyH43JTGw==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.3.1", + "@cropper/element-canvas": "^2.3.1", + "@cropper/element-selection": "^2.3.1", + "@cropper/utils": "^2.3.1" + } + }, + "node_modules/@cropper/element-viewer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cropper/element-viewer/-/element-viewer-2.3.1.tgz", + "integrity": "sha512-EdrYDbnkj0IOl/kyWKzzvcDRobr7Tfzho7FkVu8mpSk1ILDHmVf8pfZrYVwou8l4znLqp8ZqwuabAezJPfC2Yg==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.3.1", + "@cropper/element-canvas": "^2.3.1", + "@cropper/element-image": "^2.3.1", + "@cropper/element-selection": "^2.3.1", + "@cropper/utils": "^2.3.1" + } + }, + "node_modules/@cropper/elements": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cropper/elements/-/elements-2.3.1.tgz", + "integrity": "sha512-+D5rFlfFlWSL1lp0vPYI59/R6XM2vMnTSX0/B1zKRvmkc0t0N0m9lxVsaDb+9RkHx6PwAkBBNPZSRUeau33jMg==", + "license": "MIT", + "dependencies": { + "@cropper/element": "^2.3.1", + "@cropper/element-canvas": "^2.3.1", + "@cropper/element-crosshair": "^2.3.1", + "@cropper/element-grid": "^2.3.1", + "@cropper/element-handle": "^2.3.1", + "@cropper/element-image": "^2.3.1", + "@cropper/element-selection": "^2.3.1", + "@cropper/element-shade": "^2.3.1", + "@cropper/element-viewer": "^2.3.1" + } + }, + "node_modules/@cropper/utils": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@cropper/utils/-/utils-2.3.1.tgz", + "integrity": "sha512-b5qFTwws/osx0asIGLK1V//hdL08frGvfpi6s+p6zj5XH05ofVcz0Xl+Tot7sUx12pbfhhAlsquQTOJ1IsOoFw==", + "license": "MIT" + }, "node_modules/@fontsource/inter": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz", @@ -1412,6 +1533,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cropperjs": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-2.3.1.tgz", + "integrity": "sha512-bgy+iHRFjsBxBCriXkTuFuHRJGSGZISuqDGhDQG7gLK0F7qOzhH06bdXzGf4b0bZSQ+mApakybiW9xvKnS9dbg==", + "license": "MIT", + "dependencies": { + "@cropper/elements": "^2.3.1", + "@cropper/utils": "^2.3.1" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9d80fa0b..ac5cd654 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,6 +42,7 @@ "chart.js": "^4.5.1", "chartjs-chart-matrix": "^3.0.4", "chartjs-plugin-zoom": "^2.2.0", + "cropperjs": "^2.3.1", "daisyui": "^5.5.19", "dayjs": "^1.11.20", "hammerjs": "^2.0.8", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b86b55cd..7a15dce5 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -45,6 +45,8 @@ import type { SortOrder, OidcConfig, OidcLinkStatus, + AvatarOptions, + AvatarSource, PublicProfileAudience, PublicProfileLink, PublicProfileLinkCreateResponse, @@ -72,8 +74,9 @@ function authHeaders(): Record { } async function request(path: string, options?: RequestInit): Promise { + const isFormData = options?.body instanceof FormData; const headers: Record = { - 'Content-Type': 'application/json', + ...(isFormData ? {} : { 'Content-Type': 'application/json' }), ...authHeaders() }; @@ -215,6 +218,34 @@ export const api = { }); }, + getAvatarOptions(): Promise { + return request('/profile/avatar'); + }, + + setAvatarSource(source: AvatarSource): Promise { + return request('/profile/avatar', { + method: 'PATCH', + body: JSON.stringify({ source }) + }); + }, + + uploadAvatar(file: Blob, filename = 'avatar.webp'): Promise { + const form = new FormData(); + form.append('file', file, filename); + return request('/profile/avatar/upload', { + method: 'POST', + body: form + }); + }, + + removeUploadedAvatar(): Promise { + return request('/profile/avatar/upload', { method: 'DELETE' }); + }, + + refreshOidcAvatar(): Promise { + return request('/profile/avatar/refresh-oidc', { method: 'POST' }); + }, + listApiKeys(): Promise { return request('/profile/api-keys'); }, diff --git a/frontend/src/lib/components/AvatarCropper.svelte b/frontend/src/lib/components/AvatarCropper.svelte new file mode 100644 index 00000000..9c23f0da --- /dev/null +++ b/frontend/src/lib/components/AvatarCropper.svelte @@ -0,0 +1,221 @@ + + +{#if open} + +{/if} + + diff --git a/frontend/src/lib/components/UserAvatar.svelte b/frontend/src/lib/components/UserAvatar.svelte new file mode 100644 index 00000000..653ef67a --- /dev/null +++ b/frontend/src/lib/components/UserAvatar.svelte @@ -0,0 +1,38 @@ + + +{#if avatarUrl && !failed} +
+ (failed = true)} + /> +
+{:else} + +{/if} diff --git a/frontend/src/lib/components/UserAvatar.test.ts b/frontend/src/lib/components/UserAvatar.test.ts new file mode 100644 index 00000000..b0599398 --- /dev/null +++ b/frontend/src/lib/components/UserAvatar.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { render, fireEvent, cleanup } from '@testing-library/svelte'; +import UserAvatar from './UserAvatar.svelte'; + +describe('UserAvatar', () => { + afterEach(() => cleanup()); + + it('renders an image when an avatar URL is available', () => { + const { container } = render(UserAvatar, { + props: { avatarUrl: '/api/users/1/avatar?v=1', seed: 'user@example.com' } + }); + const image = container.querySelector('img'); + expect(image).toBeTruthy(); + expect(image!.getAttribute('src')).toBe('/api/users/1/avatar?v=1'); + expect(image!.getAttribute('referrerpolicy')).toBe('no-referrer'); + }); + + it('falls back to the animal avatar when the image fails to load', async () => { + const { container } = render(UserAvatar, { + props: { avatarUrl: '/api/users/1/avatar?v=1', seed: 'user@example.com' } + }); + const image = container.querySelector('img'); + expect(image).toBeTruthy(); + await fireEvent.error(image!); + expect(container.querySelector('img')).toBeNull(); + }); + + it('renders the animal avatar when no URL is provided', () => { + const { container } = render(UserAvatar, { + props: { avatarUrl: null, seed: 'user@example.com' } + }); + expect(container.querySelector('img')).toBeNull(); + expect(container.querySelector('svg')).toBeTruthy(); + }); +}); diff --git a/frontend/src/lib/components/UserMenu.svelte b/frontend/src/lib/components/UserMenu.svelte index b2a59193..bd5df5b3 100644 --- a/frontend/src/lib/components/UserMenu.svelte +++ b/frontend/src/lib/components/UserMenu.svelte @@ -5,7 +5,7 @@ import type { UpdateInfo } from '#lib/stores/updateCheck.js'; import { _ } from '#lib/i18n/index.js'; import { cycleTheme, applyThemeToDocument, saveThemeToStorage, getThemeMode, getThemeIcon, getCustomTheme, getThemeVersion } from '#lib/stores/theme.js'; - import AnimalAvatar from '#lib/components/AnimalAvatar.svelte'; + import UserAvatar from '#lib/components/UserAvatar.svelte'; import { Sun, Moon, Palette, CloudDownload, ExternalLink } from '@lucide/svelte'; let { floating = true, updateInfo = null }: { floating?: boolean; updateInfo?: UpdateInfo | null } = $props(); @@ -83,7 +83,7 @@ aria-label={$_('user.menu')} > {#if user} - + {:else}
{initials} diff --git a/frontend/src/lib/components/UserMenu.test.ts b/frontend/src/lib/components/UserMenu.test.ts index bb5c0d07..4f33a80c 100644 --- a/frontend/src/lib/components/UserMenu.test.ts +++ b/frontend/src/lib/components/UserMenu.test.ts @@ -48,7 +48,8 @@ describe('UserMenu', () => { lastname: 'Doe', email: 'john@example.com', role: 'user' as const, - created_at: '2024-01-01T00:00:00Z' + created_at: '2024-01-01T00:00:00Z', + avatar_url: null }; it('renders user avatar button', () => { diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 22a22212..3f95809b 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -699,7 +699,9 @@ "fullLibrary": "Vollständige Bibliothek", "fullLibraryTooltip": "Zeige die gesamte Bibliothek des Besitzers, durchsuchbar mit Seitenumbruch.", "statistics": "Statistiken", - "statisticsTooltip": "Zeige Lesestatistiken (unten konfigurierbar)." + "statisticsTooltip": "Zeige Lesestatistiken (unten konfigurierbar).", + "avatar": "Profilbild", + "avatarTooltip": "Zeigt dein Profilbild im öffentlichen Profil an." }, "page": { "loading": "Öffentliches Profil wird geladen...", @@ -988,5 +990,35 @@ "noChanges": "Keine Änderungen nötig.", "updateFailed": "Tag-Aktualisierung fehlgeschlagen.", "updateFailedPartial": "Tag update failed after {completed} of {total} batches. Already applied changes are kept; retry to finish." + }, + "avatar": { + "title": "Profilbild", + "uploadHint": "Wähle eine Quelle für dein Profilbild. Uploads werden vor dem Speichern quadratisch zugeschnitten.", + "sourceAnimal": "Tier-Avatar", + "sourceOidc": "OIDC-Bild", + "sourceUpload": "Hochgeladenes Bild", + "upload": "Bild hochladen", + "removeUpload": "Hochgeladenes Bild entfernen", + "refreshOidc": "OIDC-Bild aktualisieren", + "cropTitle": "Bild zuschneiden", + "cropZoomIn": "Vergrößern", + "cropZoomOut": "Verkleinern", + "cropReset": "Zurücksetzen", + "cropSave": "Bild speichern", + "cropCancel": "Abbrechen", + "oidcUnavailable": "Noch kein OIDC-Bild gespeichert. Verknüpfe OIDC oder melde dich erneut über OIDC an, um dein Anbieterbild zu laden.", + "oidcRefreshHint": "Das OIDC-Bild wird bei der nächsten Anmeldung automatisch aktualisiert.", + "uploadSuccess": "Profilbild aktualisiert.", + "uploadFailed": "Das Profilbild konnte nicht hochgeladen werden.", + "sourceUpdated": "Quelle des Profilbilds aktualisiert.", + "sourceUpdateFailed": "Die Quelle des Profilbilds konnte nicht aktualisiert werden.", + "refreshFailed": "Das OIDC-Bild konnte nicht aktualisiert werden.", + "rateLimited": "Bitte warte einen Moment, bevor du dein Profilbild erneut änderst.", + "invalidFormat": "Bitte wähle ein JPEG-, PNG- oder WebP-Bild.", + "tooLarge": "Das Bild ist zu groß. Bitte wähle eine kleinere Datei.", + "sourceAnimalHint": "Aus deinem Konto erzeugt", + "sourceOidcHint": "Von deinem Identitätsanbieter", + "sourceUploadHint": "Dein eigenes Bild", + "cropHint": "Ziehe das Bild, um es zu verschieben, und passe dann den Kreis für deinen Avatar an." } } diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 49adf340..dbf4d614 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -699,7 +699,9 @@ "fullLibrary": "Full Library", "fullLibraryTooltip": "Show the owner's entire library, browsable with pagination.", "statistics": "Statistics", - "statisticsTooltip": "Show reading statistics (configured below)." + "statisticsTooltip": "Show reading statistics (configured below).", + "avatar": "Profile picture", + "avatarTooltip": "Show your profile picture on the public profile." }, "page": { "loading": "Loading public profile...", @@ -988,5 +990,35 @@ "noChanges": "No changes needed.", "updateFailed": "Tag update failed.", "updateFailedPartial": "Tag update failed after {completed} of {total} batches. Already applied changes are kept; retry to finish." + }, + "avatar": { + "title": "Profile Picture", + "uploadHint": "Choose an avatar source. Uploads are cropped to a square before saving.", + "sourceAnimal": "Animal avatar", + "sourceOidc": "OIDC picture", + "sourceUpload": "Uploaded picture", + "upload": "Upload picture", + "removeUpload": "Remove uploaded picture", + "refreshOidc": "Refresh OIDC picture", + "cropTitle": "Crop your picture", + "cropZoomIn": "Zoom in", + "cropZoomOut": "Zoom out", + "cropReset": "Reset", + "cropSave": "Save picture", + "cropCancel": "Cancel", + "oidcUnavailable": "No OIDC picture is cached yet. Link OIDC, or sign in with OIDC again to fetch your provider picture.", + "oidcRefreshHint": "The OIDC picture is refreshed automatically at the next login.", + "uploadSuccess": "Profile picture updated.", + "uploadFailed": "Could not upload the profile picture.", + "sourceUpdated": "Profile picture source updated.", + "sourceUpdateFailed": "Could not update the profile picture source.", + "refreshFailed": "Could not refresh the OIDC picture.", + "rateLimited": "Please wait a moment before changing your profile picture again.", + "invalidFormat": "Please choose a JPEG, PNG, or WebP image.", + "tooLarge": "The image is too large. Please choose a smaller file.", + "sourceAnimalHint": "Generated from your account", + "sourceOidcHint": "From your identity provider", + "sourceUploadHint": "Your own image", + "cropHint": "Drag the image to reposition it, then resize the circle to frame your avatar." } } diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index 2b5c5c0a..0975d712 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -699,7 +699,9 @@ "fullLibrary": "Biblioteca completa", "fullLibraryTooltip": "Mostrar toda la biblioteca del propietario, con paginación.", "statistics": "Estadísticas", - "statisticsTooltip": "Mostrar estadísticas de lectura (configurables abajo)." + "statisticsTooltip": "Mostrar estadísticas de lectura (configurables abajo).", + "avatar": "Foto de perfil", + "avatarTooltip": "Muestra tu foto de perfil en el perfil público." }, "page": { "loading": "Cargando perfil público...", @@ -988,5 +990,35 @@ "noChanges": "No se necesitaron cambios.", "updateFailed": "Falló la actualización de etiquetas.", "updateFailedPartial": "Tag update failed after {completed} of {total} batches. Already applied changes are kept; retry to finish." + }, + "avatar": { + "title": "Foto de perfil", + "uploadHint": "Elige una fuente para tu foto de perfil. Las imágenes subidas se recortan en cuadrado antes de guardarse.", + "sourceAnimal": "Avatar de animal", + "sourceOidc": "Imagen de OIDC", + "sourceUpload": "Imagen subida", + "upload": "Subir imagen", + "removeUpload": "Eliminar imagen subida", + "refreshOidc": "Actualizar imagen de OIDC", + "cropTitle": "Recorta tu imagen", + "cropZoomIn": "Acercar", + "cropZoomOut": "Alejar", + "cropReset": "Restablecer", + "cropSave": "Guardar imagen", + "cropCancel": "Cancelar", + "oidcUnavailable": "Aún no hay ninguna imagen de OIDC guardada. Vincula OIDC o inicia sesión de nuevo con OIDC para obtener la imagen de tu proveedor.", + "oidcRefreshHint": "La imagen de OIDC se actualiza automáticamente en el próximo inicio de sesión.", + "uploadSuccess": "Foto de perfil actualizada.", + "uploadFailed": "No se pudo subir la foto de perfil.", + "sourceUpdated": "Fuente de la foto de perfil actualizada.", + "sourceUpdateFailed": "No se pudo actualizar la fuente de la foto de perfil.", + "refreshFailed": "No se pudo actualizar la imagen de OIDC.", + "rateLimited": "Espera un momento antes de volver a cambiar tu foto de perfil.", + "invalidFormat": "Elige una imagen JPEG, PNG o WebP.", + "tooLarge": "La imagen es demasiado grande. Elige un archivo más pequeño.", + "sourceAnimalHint": "Generado desde tu cuenta", + "sourceOidcHint": "De tu proveedor de identidad", + "sourceUploadHint": "Tu propia imagen", + "cropHint": "Arrastra la imagen para moverla y ajusta el círculo para encuadrar tu avatar." } } diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index 3212e78c..b322f7f6 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -699,7 +699,9 @@ "fullLibrary": "Bibliothèque complète", "fullLibraryTooltip": "Afficher toute la bibliothèque du propriétaire, navigable avec pagination.", "statistics": "Statistiques", - "statisticsTooltip": "Afficher les statistiques de lecture (configurables ci-dessous)." + "statisticsTooltip": "Afficher les statistiques de lecture (configurables ci-dessous).", + "avatar": "Photo de profil", + "avatarTooltip": "Afficher votre photo de profil sur le profil public." }, "page": { "loading": "Chargement du profil public...", @@ -988,5 +990,35 @@ "noChanges": "Aucune modification nécessaire.", "updateFailed": "Échec de la mise à jour des étiquettes.", "updateFailedPartial": "Tag update failed after {completed} of {total} batches. Already applied changes are kept; retry to finish." + }, + "avatar": { + "title": "Photo de profil", + "uploadHint": "Choisissez une source pour votre photo de profil. Les images téléversées sont recadrées en carré avant l’enregistrement.", + "sourceAnimal": "Avatar animal", + "sourceOidc": "Image OIDC", + "sourceUpload": "Image téléversée", + "upload": "Téléverser une image", + "removeUpload": "Supprimer l’image téléversée", + "refreshOidc": "Actualiser l’image OIDC", + "cropTitle": "Recadrez votre image", + "cropZoomIn": "Zoom avant", + "cropZoomOut": "Zoom arrière", + "cropReset": "Réinitialiser", + "cropSave": "Enregistrer l’image", + "cropCancel": "Annuler", + "oidcUnavailable": "Aucune image OIDC n’est encore en cache. Liez OIDC ou reconnectez-vous via OIDC pour récupérer l’image de votre fournisseur.", + "oidcRefreshHint": "L’image OIDC est actualisée automatiquement lors de la prochaine connexion.", + "uploadSuccess": "Photo de profil mise à jour.", + "uploadFailed": "Impossible de téléverser la photo de profil.", + "sourceUpdated": "Source de la photo de profil mise à jour.", + "sourceUpdateFailed": "Impossible de mettre à jour la source de la photo de profil.", + "refreshFailed": "Impossible d’actualiser l’image OIDC.", + "rateLimited": "Veuillez patienter un instant avant de modifier à nouveau votre photo de profil.", + "invalidFormat": "Choisissez une image JPEG, PNG ou WebP.", + "tooLarge": "L’image est trop volumineuse. Choisissez un fichier plus petit.", + "sourceAnimalHint": "Généré depuis votre compte", + "sourceOidcHint": "Depuis votre fournisseur d’identité", + "sourceUploadHint": "Votre propre image", + "cropHint": "Faites glisser l’image pour la repositionner, puis ajustez le cercle pour cadrer votre avatar." } } diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index 3988d594..ab4f84b5 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -699,7 +699,9 @@ "fullLibrary": "完整书库", "fullLibraryTooltip": "显示所有者的整个书库,可翻页浏览。", "statistics": "统计", - "statisticsTooltip": "显示阅读统计(可在下方配置)。" + "statisticsTooltip": "显示阅读统计(可在下方配置)。", + "avatar": "个人头像", + "avatarTooltip": "在公开资料中显示你的个人头像。" }, "page": { "loading": "正在加载公开资料...", @@ -988,5 +990,35 @@ "noChanges": "无需更改。", "updateFailed": "标签更新失败。", "updateFailedPartial": "Tag update failed after {completed} of {total} batches. Already applied changes are kept; retry to finish." + }, + "avatar": { + "title": "个人头像", + "uploadHint": "选择头像来源。上传的图片在保存前会被裁剪为正方形。", + "sourceAnimal": "动物头像", + "sourceOidc": "OIDC 图片", + "sourceUpload": "上传的图片", + "upload": "上传图片", + "removeUpload": "删除上传的图片", + "refreshOidc": "刷新 OIDC 图片", + "cropTitle": "裁剪图片", + "cropZoomIn": "放大", + "cropZoomOut": "缩小", + "cropReset": "重置", + "cropSave": "保存图片", + "cropCancel": "取消", + "oidcUnavailable": "尚未缓存 OIDC 图片。请关联 OIDC,或再次通过 OIDC 登录以获取提供商图片。", + "oidcRefreshHint": "OIDC 图片会在下次登录时自动刷新。", + "uploadSuccess": "个人头像已更新。", + "uploadFailed": "无法上传个人头像。", + "sourceUpdated": "头像来源已更新。", + "sourceUpdateFailed": "无法更新头像来源。", + "refreshFailed": "无法刷新 OIDC 图片。", + "rateLimited": "请稍等片刻再更改个人头像。", + "invalidFormat": "请选择 JPEG、PNG 或 WebP 图片。", + "tooLarge": "图片过大,请选择较小的文件。", + "sourceAnimalHint": "根据你的账户生成", + "sourceOidcHint": "来自你的身份提供商", + "sourceUploadHint": "你自己的图片", + "cropHint": "拖动图片调整位置,然后调整圆形区域来裁剪你的头像。" } } diff --git a/frontend/src/lib/publicProfile/sections.test.ts b/frontend/src/lib/publicProfile/sections.test.ts index 52a7108f..fc23ecb0 100644 --- a/frontend/src/lib/publicProfile/sections.test.ts +++ b/frontend/src/lib/publicProfile/sections.test.ts @@ -17,6 +17,7 @@ const statisticKeys: PublicProfileStatisticsKey[] = PUBLIC_PROFILE_STATISTICS.ma const ALL_SECTIONS: PublicProfileSectionKey[] = [ 'username', 'user_info', + 'avatar', 'currently_reading', 'last_read', 'reading_timeline', diff --git a/frontend/src/lib/publicProfile/sections.ts b/frontend/src/lib/publicProfile/sections.ts index 80573507..e77da178 100644 --- a/frontend/src/lib/publicProfile/sections.ts +++ b/frontend/src/lib/publicProfile/sections.ts @@ -25,6 +25,12 @@ export const PUBLIC_PROFILE_SECTIONS: PublicProfileSectionDef[] = [ tooltipKey: 'publicProfile.sections.userInfoTooltip', defaultOn: true }, + { + key: 'avatar', + i18nKey: 'publicProfile.sections.avatar', + tooltipKey: 'publicProfile.sections.avatarTooltip', + defaultOn: false + }, { key: 'currently_reading', i18nKey: 'publicProfile.sections.currentlyReading', diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 795a4364..1201e614 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -222,6 +222,16 @@ export type StatisticsRange = 'alltime' | 'this_year' | 'last_year' | '3years' | export type UserRole = 'admin' | 'user'; +export type AvatarSource = 'animal' | 'oidc' | 'upload'; + +export interface AvatarOptions { + source: AvatarSource; + avatar_url: string | null; + has_uploaded_avatar: boolean; + has_oidc_avatar: boolean; + oidc_linked: boolean; +} + export interface User { id: number; firstname: string; @@ -229,6 +239,7 @@ export interface User { email: string; role: UserRole; created_at: string; + avatar_url: string | null; } export interface UserCreateResponse { @@ -330,6 +341,8 @@ export interface AppConfig { thalia_cover_search_enabled: boolean; password_login_enabled: boolean; trusted_proxy_auth_enabled: boolean; + avatar_max_upload_mb: number; + avatar_max_dimension: number; } export interface OidcLinkStatus { @@ -570,6 +583,7 @@ export type PublicProfileAudience = 'public' | 'authenticated'; export type PublicProfileSectionKey = | 'username' | 'user_info' + | 'avatar' | 'currently_reading' | 'last_read' | 'reading_timeline' @@ -627,6 +641,7 @@ export interface ShareLinkRevealResponse { export interface PublicProfileUserInfo { firstname: string; lastname: string; + avatar_url: string | null; } export interface PublicProfileBook { diff --git a/frontend/src/routes/p/[token]/+page.svelte b/frontend/src/routes/p/[token]/+page.svelte index 91166590..014ec78f 100644 --- a/frontend/src/routes/p/[token]/+page.svelte +++ b/frontend/src/routes/p/[token]/+page.svelte @@ -4,7 +4,7 @@ import { resolve } from '$app/paths'; import { _, setLocale, SUPPORTED_LOCALES, locale } from '#lib/i18n/index.js'; import { api } from '#lib/api.js'; - import AnimalAvatar from '#lib/components/AnimalAvatar.svelte'; + import UserAvatar from '#lib/components/UserAvatar.svelte'; import { Moon, Palette, Star, Sun } from '@lucide/svelte'; import { applyThemeToDocument, @@ -382,6 +382,17 @@

{/if} + {#if hasSection('avatar')} +
+ +
+ {/if} + {#if hasSection('username')}

{profile.owner.firstname} {profile.owner.lastname}

{/if} @@ -390,11 +401,14 @@
- + {#if !hasSection('avatar')} + + {/if}

{$_('publicProfile.sections.userInfo')}

diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index c364d914..4edce794 100644 --- a/frontend/src/routes/profile/+page.svelte +++ b/frontend/src/routes/profile/+page.svelte @@ -4,7 +4,7 @@ import { api } from '#lib/api.js'; import PasswordRequirements from '#lib/components/PasswordRequirements.svelte'; import { currentUser } from '#lib/stores/auth.js'; - import { Calendar, Check, Copy, ExternalLink, Info, Pencil, Trash2 } from '@lucide/svelte'; + import { BadgeCheck, Calendar, Check, Copy, ExternalLink, ImagePlus, Info, PawPrint, Pencil, RefreshCw, Trash2 } from '@lucide/svelte'; import { _, SUPPORTED_LOCALES, setLocale } from '#lib/i18n/index.js'; import { getPasswordChecks, passwordChecksPassed, passwordPattern } from '#lib/password.js'; import { getTimezone, setTimezone, detectTimezone } from '#lib/stores/timezone.js'; @@ -12,6 +12,8 @@ import Alert from '#lib/components/Alert.svelte'; import SearchableSelect from '#lib/components/SearchableSelect.svelte'; import AdaptiveDateInput from '#lib/components/AdaptiveDateInput.svelte'; + import UserAvatar from '#lib/components/UserAvatar.svelte'; + import AvatarCropper from '#lib/components/AvatarCropper.svelte'; import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; import { toasts } from '#lib/toasts.js'; import { localizeError } from '#lib/errors.js'; @@ -19,6 +21,8 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; import type { ApiKeyMeta, AppConfig, + AvatarOptions, + AvatarSource, EmbedTokenMeta, OidcConfig, OidcLinkStatus, @@ -42,10 +46,17 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; let keys = $state([]); let pendingDeleteKeyId = $state(null); let oidcConfig = $state({ enabled: false, provider_id: null, provider_name: null, auto_redirect: false }); - let appConfig = $state({ embed_enabled: true, dashboard_quote_enabled: true, thalia_cover_search_enabled: false, password_login_enabled: true, trusted_proxy_auth_enabled: false }); + let appConfig = $state({ embed_enabled: true, dashboard_quote_enabled: true, thalia_cover_search_enabled: false, password_login_enabled: true, trusted_proxy_auth_enabled: false, avatar_max_upload_mb: 10, avatar_max_dimension: 512 }); let oidcLink = $state({ linked: false, provider_name: null, oidc_email: null, oidc_name: null }); let oidcLoading = $state(false); let oidcMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); + let avatarOptions = $state(null); + let avatarMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); + let avatarSaving = $state(false); + let cropperOpen = $state(false); + let cropperFile = $state(null); + let cropperError = $state(null); + let avatarInput: HTMLInputElement | undefined = $state(); let themeMode = $state(getThemeMode()); let customTheme = $state(getCustomTheme() ?? 'dracula'); let themeMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); @@ -173,6 +184,7 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; if (oidcConfig.enabled) { oidcLink = await api.oidc.linkStatus(); } + avatarOptions = await api.profile.getAvatarOptions(); } onMount(() => { @@ -213,6 +225,127 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte'; } } + async function refreshCurrentUser() { + const me = await api.auth.me(); + currentUser.set(me); + } + + function avatarErrorText(error: unknown, fallbackKey: string): string { + if (error instanceof Error && (error as Error & { status?: number }).status === 429) { + return $_('avatar.rateLimited'); + } + return error instanceof Error ? error.message : $_(fallbackKey); + } + + function avatarSourceCardClass(selected: boolean, available: boolean): string { + return [ + 'group flex cursor-pointer items-start gap-3 rounded-xl border p-3 transition-colors', + selected ? 'border-primary/50 bg-primary/5' : 'border-base-200 bg-base-100 hover:border-base-300', + available ? '' : 'cursor-not-allowed opacity-50' + ].join(' '); + } + + function avatarSourceIconClass(selected: boolean): string { + return `grid h-8 w-8 shrink-0 place-items-center rounded-lg ${ + selected ? 'bg-primary/10 text-primary' : 'bg-base-200 text-base-content/70' + }`; + } + + function pickAvatarFile() { + avatarInput?.click(); + } + + function onAvatarFileChange(event: Event) { + const input = event.currentTarget as HTMLInputElement; + const selected = input.files?.[0]; + input.value = ''; + if (!selected) return; + + avatarMessage = null; + const allowed = ['image/jpeg', 'image/png', 'image/webp']; + if (!allowed.includes(selected.type)) { + avatarMessage = { type: 'error', text: $_('avatar.invalidFormat') }; + return; + } + if (selected.size > appConfig.avatar_max_upload_mb * 1024 * 1024) { + avatarMessage = { type: 'error', text: $_('avatar.tooLarge') }; + return; + } + + cropperFile = selected; + cropperError = null; + cropperOpen = true; + } + + async function saveCroppedAvatar(blob: Blob) { + avatarSaving = true; + avatarMessage = null; + cropperError = null; + try { + avatarOptions = await api.profile.uploadAvatar(blob, 'avatar.webp'); + await refreshCurrentUser(); + cropperOpen = false; + cropperFile = null; + avatarMessage = { type: 'success', text: $_('avatar.uploadSuccess') }; + } catch (e: unknown) { + cropperError = avatarErrorText(e, 'avatar.uploadFailed'); + } finally { + avatarSaving = false; + } + } + + async function setAvatarSource(source: AvatarSource) { + if (!avatarOptions || avatarOptions.source === source) return; + avatarSaving = true; + avatarMessage = null; + try { + avatarOptions = await api.profile.setAvatarSource(source); + await refreshCurrentUser(); + avatarMessage = { type: 'success', text: $_('avatar.sourceUpdated') }; + } catch (e: unknown) { + avatarMessage = { + type: 'error', + text: avatarErrorText(e, 'avatar.sourceUpdateFailed') + }; + } finally { + avatarSaving = false; + } + } + + async function removeUploadedAvatar() { + avatarSaving = true; + avatarMessage = null; + try { + avatarOptions = await api.profile.removeUploadedAvatar(); + await refreshCurrentUser(); + avatarMessage = { type: 'success', text: $_('common.saved') }; + } catch (e: unknown) { + avatarMessage = { + type: 'error', + text: avatarErrorText(e, 'avatar.uploadFailed') + }; + } finally { + avatarSaving = false; + } + } + + async function refreshOidcAvatar() { + avatarSaving = true; + avatarMessage = null; + try { + avatarOptions = await api.profile.refreshOidcAvatar(); + await refreshCurrentUser(); + avatarMessage = { type: 'success', text: $_('avatar.sourceUpdated') }; + } catch (e: unknown) { + avatarMessage = { + type: 'error', + text: avatarErrorText(e, 'avatar.refreshFailed') + }; + } finally { + avatarSaving = false; + } + } + async function saveLanguage() { const updated = await api.profile.updateSettings({ language }); if (SUPPORTED_LOCALES.includes(updated.language as (typeof SUPPORTED_LOCALES)[number])) { @@ -725,6 +858,125 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte';

+
+
+
+

{$_('avatar.title')}

+

{$_('avatar.uploadHint')}

+
+ {#if avatarMessage} + avatarMessage = null} + >{avatarMessage.text} + {/if} + +
+
+ +
+ +
+
+ + + + + +
+ + {#if avatarOptions && !avatarOptions.has_oidc_avatar} +
+ + {$_('avatar.oidcUnavailable')} +
+ {/if} + +
+ + + {#if avatarOptions?.has_uploaded_avatar} + + {/if} + {#if avatarOptions?.has_oidc_avatar} + + {/if} +
+ +

{$_('avatar.oidcRefreshHint')}

+
+
+
+
+

{$_('settings.languageTitle')}

@@ -1363,6 +1615,7 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte';