From d104305ca3d1e2fd21cb0ee569681bf833cfcf8c Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 30 Oct 2026 20:34:44 +0100 Subject: [PATCH 01/19] Add the profile picture model, storage, and migration --- .env.example | 6 + ...e7f8a9_add_user_image_and_avatar_source.py | 66 ++++ backend/app/config.py | 6 + backend/app/models.py | 47 +++ backend/app/services/avatar_storage.py | 311 ++++++++++++++++++ backend/pyproject.toml | 1 + docs/guide/configuration.md | 17 + uv.lock | 52 +++ 8 files changed, 506 insertions(+) create mode 100644 backend/alembic/versions/b4c5d6e7f8a9_add_user_image_and_avatar_source.py create mode 100644 backend/app/services/avatar_storage.py diff --git a/.env.example b/.env.example index 93200c63..876906a9 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 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..f8f8729d 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 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/services/avatar_storage.py b/backend/app/services/avatar_storage.py new file mode 100644 index 00000000..46324753 --- /dev/null +++ b/backend/app/services/avatar_storage.py @@ -0,0 +1,311 @@ +"""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) -> bool: + """Return True for an HTTPS OIDC picture URL that is safe to download.""" + 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 + + 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, +) -> 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): + 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/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/docs/guide/configuration.md b/docs/guide/configuration.md index a653bd96..ed388aad 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -209,6 +209,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 +300,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/uv.lock b/uv.lock index eea94d56..8636394d 100644 --- a/uv.lock +++ b/uv.lock @@ -766,6 +766,7 @@ dependencies = [ { name = "itsdangerous" }, { name = "openpyxl" }, { name = "passlib", extra = ["bcrypt"] }, + { name = "pillow" }, { name = "playwright" }, { name = "pycountry" }, { name = "pydantic-settings" }, @@ -800,6 +801,7 @@ requires-dist = [ { name = "itsdangerous", specifier = ">=2.2.0" }, { name = "openpyxl", specifier = ">=3.1.5" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, + { name = "pillow", specifier = ">=11.0.0" }, { name = "playwright", specifier = ">=1.62.0" }, { name = "pycountry", specifier = ">=24.6.1" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, @@ -1176,6 +1178,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/02/db/f7f470bf092cdd32d6fe410b970bb8db7ddac443528124218015742c0016/patchright-1.64.3-py3-none-win_arm64.whl", hash = "sha256:fa2e42f5c75a4f47cb04a0a721fd97f96f2c2c45e2f48f771dcabbbf7c1e202c", size = 34683780, upload-time = "2026-10-25T13:17:58.211Z" }, ] +[[package]] +name = "pillow" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/a4/9cc30690a394a8305e8be4c9036aa16d0dfddadd09079860b582f562496d/pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510", size = 47058631, upload-time = "2026-10-16T22:18:09.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/45/fbad9c7f5e2f1745c373a434d46072bc84eddfefaa28b852425ac957fda6/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:44629d4498c9cb6b2051806155f22141df0d8a929c56134b166655232dfb2f8c", size = 4190721, upload-time = "2026-10-16T22:16:27.524Z" }, + { url = "https://files.pythonhosted.org/packages/a2/bf/2074902a259a90692c5dbf132885d21671644cbbd3f690047516243a55db/pillow-13.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:bf97ba1b5542050e9b0c74e632323b6d8136cc99d64e193abebee22fe6013c76", size = 4290236, upload-time = "2026-10-16T22:16:29.562Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a5/3d1bb93b8ea38d15d21356c1b8e9f50971ac592aaa248f7fb71739094c0e/pillow-13.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:aa694c49787040943570d61c9b8fc01271bd2a2cfdd55f0d896f8aecdce1a981", size = 3723093, upload-time = "2026-10-16T22:16:31.249Z" }, + { url = "https://files.pythonhosted.org/packages/84/a1/be060135d85187b25fa8b2573786c0e45c62ae27527fff5257675eb97f4a/pillow-13.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:49c12af515073e9bf511e4ef736743bf70d531a643668722279314b937742ba4", size = 5572253, upload-time = "2026-10-16T22:16:33.103Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/0bf6f1ac9ba7ee2ffe9914aeb410a1035a0c1a3c42f122388d7fc950bc4b/pillow-13.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d9b0a2bee3f3e03e9f29057aedb807dbf1e1d96ee1b9d0f422d9db6c8583271d", size = 4852269, upload-time = "2026-10-16T22:16:35.102Z" }, + { url = "https://files.pythonhosted.org/packages/83/16/b603274617b3e6728da3a0133d4fba1ea8d7fbe0c4ea5fef9cbffa398b8b/pillow-13.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5933fcea5a5ba64c686f55c886a115cbfd92f2900460784a1e79909fdbafd7b6", size = 6516058, upload-time = "2026-10-16T22:16:37.228Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f2/1371707ce438ba9a43f9d24089828494ec6f9396e28cd7903aa704dd6306/pillow-13.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91c2b1cbeae768c98752e67e717e283d68dae45717b41f2d91eaee648e37b7d", size = 7142574, upload-time = "2026-10-16T22:16:39.172Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a9/62cf68587f910bc87c5951bcb6e9430c7643a65076adf5f18b4bf350e2bd/pillow-13.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f6299e494e46253d40c34653cac369412f44ca53b5f85d848d3f95b1ed0546c", size = 6614295, upload-time = "2026-10-16T22:16:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c9/9e9b48178649730299be0bafbf5f810a5b6977538f5a92d47e4ba5f5d608/pillow-13.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f9bd7d260d986f3553518c5d723544346fa48031f8aef3265d786aeb59845bc0", size = 7224841, upload-time = "2026-10-16T22:16:43.118Z" }, + { url = "https://files.pythonhosted.org/packages/8f/48/c44e91384b1b6c072a714a63d00608acc16a6a968735e478e4be4384e5ac/pillow-13.0.0-cp314-cp314-win32.whl", hash = "sha256:2e31c4e68cffd7b1f116127626813b82fcba19edf6b940a7b0c266d6484d0055", size = 6508694, upload-time = "2026-10-16T22:16:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/7ad17747517e4c813dca78257d6bb593466f8231baf1384bc98d92745edb/pillow-13.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:f7cbb7a64e7ec664775195e4f81923bc8a93d9451b963f844e59118f63147a7a", size = 7281830, upload-time = "2026-10-16T22:16:47.725Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/e5b51f2ce8ef4563215334035403ce61a244f93a2e65c24a940131618f87/pillow-13.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6aaa7871aebca081eb5c45f83e845bbd9712ae99fa9286dbfdcee835e63f4b4", size = 2695468, upload-time = "2026-10-16T22:16:49.515Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/a53502f731f6c84805a4ce87a8881e86d07a65aee64728b900d112dc703e/pillow-13.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b999816b5850a35ada08443e6e03945f1f6488144a83afe2d37b240a552aafa", size = 5578196, upload-time = "2026-10-16T22:16:51.291Z" }, + { url = "https://files.pythonhosted.org/packages/21/50/7e949959a56b5559c78ef172b70fc6025706acb289f3f648a15329e4fa6f/pillow-13.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:67871a13cf47275201553b536c025da777f997fa562a19eb798af4ad4b97c18f", size = 4857276, upload-time = "2026-10-16T22:16:53.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/52/43ded0ac08e77e39d17bd6d894162572201b5e8fdc4a022b14c55a1960a4/pillow-13.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8ca8f69b7c77fa96dad99f8638c1fc4f1c0bed4acaa1baf7a3c31bdb80a0ab1", size = 6607008, upload-time = "2026-10-16T22:16:55.525Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2e/4a57ed216d5bf877387cba4d44c02aa87abe51ad2e06404aa6b597c4da39/pillow-13.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bce6a29c3898191f4c9998e4d4e8f69790caeabde46e54da4bc235ce5bde683", size = 7224281, upload-time = "2026-10-16T22:16:57.738Z" }, + { url = "https://files.pythonhosted.org/packages/5c/3d/2929b248e135752d63aaeabf53f6de797e905b7b6263f9c1e2adade20aba/pillow-13.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:816752a5ef76a80484c8c441c85593b7ea9304f6231fc5776ad1fbff7e773058", size = 6695600, upload-time = "2026-10-16T22:16:59.842Z" }, + { url = "https://files.pythonhosted.org/packages/bc/33/fb0da992cf084ed1ee6ee05e96b6191e35c351eb707ca2df874feb24ce47/pillow-13.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5bdb38ba220b61b4ec31dd1046dea6b016d3c6850a23f43ed8b58332b2fc668e", size = 7300589, upload-time = "2026-10-16T22:17:01.686Z" }, + { url = "https://files.pythonhosted.org/packages/bb/af/c30dd9ef6a3dce84ecb1b2393cac629047a32e66279056c6cb46c3ffd65c/pillow-13.0.0-cp314-cp314t-win32.whl", hash = "sha256:06903aac074d00c988e77a1fc435286dc498506d4ca8d6f69df9babddf4ecc15", size = 6517204, upload-time = "2026-10-16T22:17:03.722Z" }, + { url = "https://files.pythonhosted.org/packages/85/d5/ef3a8ae7609c8ab26570abf18194882534b480d245a9148b895a2262c004/pillow-13.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9a635068d9793c7f0d929f39f18b58f1bd86c236e8e7b64167b86af9232ebca0", size = 7286695, upload-time = "2026-10-16T22:17:05.543Z" }, + { url = "https://files.pythonhosted.org/packages/84/3c/0861db0c69a03689ebe3e7599d35668a414deb5aaafd8255215e4e62e3d3/pillow-13.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0d1a9cf7bc6917538b20f8d06b9ab2147e542f68849e96fc58eb66bdf6acc692", size = 2698098, upload-time = "2026-10-16T22:17:07.674Z" }, + { url = "https://files.pythonhosted.org/packages/6e/eb/e86a41962a3a31ad495a3d6a912d9002b01bba4a6fa71c28703abdca7f69/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7d5e097eabd5d4915c27ac9f6e9325bb1e01a7c94d7348b5c4451d3fc30e8521", size = 4191213, upload-time = "2026-10-16T22:17:09.449Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/7e7445e706afec84ef06dc25b2c77f9cecff242c23730c62928ad1089d4a/pillow-13.0.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:51ebb529fc5e8abdd55302f6c3f8943037348ec2384329a3e55677f69ae2fdfc", size = 4290650, upload-time = "2026-10-16T22:17:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/46/85/d534089068ac40c0f61f650389a296c812eab745a8a733f81feec39ae955/pillow-13.0.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c87a04e7f152ee4db5dbe15f0bfa3fe628a1aeedbe66b81c43b87f52c813f9ac", size = 3723078, upload-time = "2026-10-16T22:17:12.772Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2b/00b5c3b401df538b5dbe311d3899730fae5ba1e21cb8b80a128887ac6747/pillow-13.0.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:20722cd5375533ad754053363a80b8fb30c79d37930a6f7228030e237ce3bd5d", size = 5572292, upload-time = "2026-10-16T22:17:14.539Z" }, + { url = "https://files.pythonhosted.org/packages/72/f5/a6b0610f946e9b54ae888ba7ad29d6e2f8d9b11f69e212d70416b7186c09/pillow-13.0.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:04a63720f79e22fd54d897c233a68314c50ca0603360f54ce2bd5db8f0f43782", size = 4852222, upload-time = "2026-10-16T22:17:16.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d0/c0ff25bb0427ec70f32aac995453321fc3c4494a46c7292ff02d9d260b04/pillow-13.0.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f8db54177035a2b5f717f94475b74907e12a96dc74f6d487f3966b0a4694071", size = 6527349, upload-time = "2026-10-16T22:17:18.534Z" }, + { url = "https://files.pythonhosted.org/packages/85/11/e46fa0bcc0fc117f1eacc206b3d51f04d583fab328b4b740672d0438d83f/pillow-13.0.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f36aa77f74b14a2103622b196402c71330e4e7cf4122ee45c854e9a01e0eecf", size = 7152873, upload-time = "2026-10-16T22:17:20.415Z" }, + { url = "https://files.pythonhosted.org/packages/7c/53/2bdeca705f3a58679fa9201233193c86a30238340ca29b3a152c3fcc719b/pillow-13.0.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:149a1c323ba366f90b48e6f1a6db6582eb50512df374588de56fc909abc0f0f3", size = 6626871, upload-time = "2026-10-16T22:17:22.361Z" }, + { url = "https://files.pythonhosted.org/packages/14/de/3829030f209722ade3753bb456b805a0e7f885cf885b483407365c82e9ca/pillow-13.0.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e892354a424b1e82a78ba16785822bdd40de06f19446a3d4a28047df1a4f8932", size = 7236439, upload-time = "2026-10-16T22:17:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/44/b1/67fc37388382bc036a74f04da5bb6fcbf90ec3ac1f45893e06176c373fd3/pillow-13.0.0-cp315-cp315-win32.whl", hash = "sha256:8e459b613cf31f8980f584387b4fe47bf08e076e288d61aa8eba1f7fd4ddbb81", size = 6508688, upload-time = "2026-10-16T22:17:26.314Z" }, + { url = "https://files.pythonhosted.org/packages/44/05/fdb6a79aee10861165526b41b7b440e36ee1ff58613b2fbefe3acbf06d4f/pillow-13.0.0-cp315-cp315-win_amd64.whl", hash = "sha256:d4737f50d23ddd11d6685faacbb9252887dde0bc7997127757247983d74399d6", size = 7281107, upload-time = "2026-10-16T22:17:28.198Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9c/6c6f7dc2a566467baf121394e83de25ac287cffe34cc030726beb4be0224/pillow-13.0.0-cp315-cp315-win_arm64.whl", hash = "sha256:2310e2adfc13c6d457d7d0f2ffdd5c56a74a317650cf765887df8d515a647ac0", size = 2695513, upload-time = "2026-10-16T22:17:30.013Z" }, + { url = "https://files.pythonhosted.org/packages/23/16/e9120defdb8bff693b582d035312acc6f530cdc2bc5238a87f410efccd82/pillow-13.0.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:21c32061a5512dc13f87cc7303e120e4d77c2f70df89a35af0e3ce246e5ac57f", size = 5578129, upload-time = "2026-10-16T22:17:31.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/22/76faf2bb2448f48a84cf894c1e2bb1090d7d2b1aa521ece131cb2f05d631/pillow-13.0.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c774b9c0ba0d0b356f71530677b39028878a2cae5a93e0a13779cfd17120f88", size = 4857428, upload-time = "2026-10-16T22:17:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/b3/64/0861ea4e03c50b14af9ed9dd8b9f388ab42f445c623a9fcbab19aef7aa55/pillow-13.0.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20822140c9a2ba9c5e42df342b0a892ab2e102fc0e3db967a133bb9ce3f898bb", size = 6612582, upload-time = "2026-10-16T22:17:35.415Z" }, + { url = "https://files.pythonhosted.org/packages/5c/20/cd1e9b3f60a9f3d5ebe46da36955c26004092a501f7f2c42f9fcc26611bc/pillow-13.0.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffea6b2986a797c84b6d1d27d4f43e0b83c73fc16281a930891cd237cf0d08fe", size = 7230176, upload-time = "2026-10-16T22:17:37.362Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ef/ffb0a2fc85690714fb7ac662956949645d57f15bf4426f520da361e7b6e3/pillow-13.0.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:05cd2cd39055d0b9650060e4513ef4a9a0aea870083950bd046a0d6490f93a22", size = 6703640, upload-time = "2026-10-16T22:17:39.313Z" }, + { url = "https://files.pythonhosted.org/packages/e0/c2/6402ee3ccb3363aeed8fdb3f9fe56703ac5377a539f0b81b53999d7e121f/pillow-13.0.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c6fd031333dedcad7ce3e03d244093f43504f209e86925ebdb2b868fa4037630", size = 7309231, upload-time = "2026-10-16T22:17:41.021Z" }, + { url = "https://files.pythonhosted.org/packages/fc/96/d6b82e8b8c06ad143b66b12e0b31051e4bf489b4fabca1f390a14fe1ebde/pillow-13.0.0-cp315-cp315t-win32.whl", hash = "sha256:c50c80e3591e5cf9a8dfa6007d4f10732f02e32263850999286e56ccd650cfb2", size = 6517200, upload-time = "2026-10-16T22:17:42.886Z" }, + { url = "https://files.pythonhosted.org/packages/60/d4/0062e6005e9b270b165015aafd343364ac378ec5a685431ea42baad33357/pillow-13.0.0-cp315-cp315t-win_amd64.whl", hash = "sha256:adb3c2124c68e480f4632d06cb7caa3b48a51678475f483b4edb1c6dee29ea43", size = 7287488, upload-time = "2026-10-16T22:17:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/e4/81/b8eb349746be7bce47dd95337c6634e84910af91b59aaa9159c034c614fa/pillow-13.0.0-cp315-cp315t-win_arm64.whl", hash = "sha256:4a25756244bdcce3b50580baadf320fa48297f0eeb8e114ba64139a40d4d0fc8", size = 2698073, upload-time = "2026-10-16T22:17:47.556Z" }, +] + [[package]] name = "playwright" version = "1.64.1" From 32587865c3f701208c7f7de243c91c2bc8bf853a Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 30 Oct 2026 20:34:51 +0100 Subject: [PATCH 02/19] Add the authenticated profile picture API and user serialization --- backend/app/routers/auth.py | 12 +- backend/app/routers/config.py | 2 + backend/app/routers/profile.py | 221 +++++++++++++++++++++++++++++++- backend/app/routers/users.py | 76 ++++++++++- backend/app/schemas.py | 23 +++- backend/app/services/avatar.py | 227 +++++++++++++++++++++++++++++++++ 6 files changed, 542 insertions(+), 19 deletions(-) create mode 100644 backend/app/services/avatar.py 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/profile.py b/backend/app/routers/profile.py index 4358f08d..b51faf79 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,13 @@ ) 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.schemas import ( ApiKeyCreate, ApiKeyCreateResponse, ApiKeyRead, + AvatarOptionsRead, + AvatarSourceUpdate, ConfirmationPhrase, DataResetDeleted, DataResetResponse, @@ -37,6 +41,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 +70,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 +90,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 +103,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 +115,178 @@ 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, + ) + 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, + ) + 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 +437,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/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..51183cbb 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): 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, + ) From 37878f8f14aa1a37817929e441052009684908f2 Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 30 Oct 2026 20:34:58 +0100 Subject: [PATCH 03/19] Add the public profile picture section and endpoint --- backend/app/routers/public_profile.py | 83 ++++++++++++++++++++++----- backend/app/schemas.py | 2 + 2 files changed, 72 insertions(+), 13 deletions(-) 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/schemas.py b/backend/app/schemas.py index 51183cbb..29025674 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -917,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" @@ -1013,6 +1014,7 @@ class PublicProfileUserInfo(SQLModel): firstname: str | None = None lastname: str | None = None + avatar_url: Optional[str] = None class PublicProfileBook(SQLModel): From e62a22781e89236b8b190c5161e4d4d6d9e43cb8 Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 30 Oct 2026 20:35:02 +0100 Subject: [PATCH 04/19] Cache OIDC profile pictures during login and linking --- backend/app/routers/oidc.py | 106 +++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 2 deletions(-) diff --git a/backend/app/routers/oidc.py b/backend/app/routers/oidc.py index 0f405f05..c06912e3 100644 --- a/backend/app/routers/oidc.py +++ b/backend/app/routers/oidc.py @@ -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.models import AvatarSource, OidcLink, User from app.oidc import get_oidc_client, oidc_is_enabled 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,60 @@ 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): + 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, + ) + + 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"} @@ -213,6 +279,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 +410,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 +432,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) From 87898b4f91669846b0274c9923dffa6838556d82 Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 30 Oct 2026 20:35:05 +0100 Subject: [PATCH 05/19] Clean up profile picture files and include them in backups --- backend/app/main.py | 12 +++++++++++ backend/app/routers/admin.py | 2 ++ backend/app/services/backup_restore.py | 30 +++++++++++++++++++++++--- backend/app/services/user_deletion.py | 5 ++++- 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index d11b347e..4cd9bb75 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() 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/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() From 195b6c81cb0e4f414be70eba5875f727878ae381 Mon Sep 17 00:00:00 2001 From: codebude Date: Fri, 30 Oct 2026 20:35:13 +0100 Subject: [PATCH 06/19] Add the profile picture editor and cropper --- frontend/package-lock.json | 131 ++++++++++ frontend/package.json | 1 + frontend/src/lib/api.ts | 33 ++- .../src/lib/components/AvatarCropper.svelte | 179 ++++++++++++++ frontend/src/lib/components/UserAvatar.svelte | 38 +++ .../src/lib/components/UserAvatar.test.ts | 35 +++ frontend/src/lib/components/UserMenu.svelte | 4 +- frontend/src/lib/components/UserMenu.test.ts | 3 +- frontend/src/lib/i18n/locales/de.json | 24 ++ frontend/src/lib/i18n/locales/en.json | 24 ++ frontend/src/lib/i18n/locales/es.json | 24 ++ frontend/src/lib/i18n/locales/fr.json | 24 ++ frontend/src/lib/i18n/locales/zh.json | 24 ++ frontend/src/lib/types.ts | 13 + frontend/src/routes/profile/+page.svelte | 231 +++++++++++++++++- 15 files changed, 782 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/components/AvatarCropper.svelte create mode 100644 frontend/src/lib/components/UserAvatar.svelte create mode 100644 frontend/src/lib/components/UserAvatar.test.ts 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..7745bc5b --- /dev/null +++ b/frontend/src/lib/components/AvatarCropper.svelte @@ -0,0 +1,179 @@ + + +{#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..51df6c85 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -988,5 +988,29 @@ "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": "Kein OIDC-Bild verfügbar. Verknüpfe zuerst OIDC oder hinterlege ein Bild beim Anbieter.", + "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." } } diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index 49adf340..4ff34305 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -988,5 +988,29 @@ "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 available. Link OIDC or set a provider picture first.", + "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." } } diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index 2b5c5c0a..44cc32e3 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -988,5 +988,29 @@ "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": "No hay ninguna imagen de OIDC disponible. Vincula OIDC o configura una imagen en el 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." } } diff --git a/frontend/src/lib/i18n/locales/fr.json b/frontend/src/lib/i18n/locales/fr.json index 3212e78c..7e6dbd1b 100644 --- a/frontend/src/lib/i18n/locales/fr.json +++ b/frontend/src/lib/i18n/locales/fr.json @@ -988,5 +988,29 @@ "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 disponible. Liez OIDC ou définissez une image chez le 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." } } diff --git a/frontend/src/lib/i18n/locales/zh.json b/frontend/src/lib/i18n/locales/zh.json index 3988d594..64dce3e9 100644 --- a/frontend/src/lib/i18n/locales/zh.json +++ b/frontend/src/lib/i18n/locales/zh.json @@ -988,5 +988,29 @@ "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 或在提供商处设置图片。", + "oidcRefreshHint": "OIDC 图片会在下次登录时自动刷新。", + "uploadSuccess": "个人头像已更新。", + "uploadFailed": "无法上传个人头像。", + "sourceUpdated": "头像来源已更新。", + "sourceUpdateFailed": "无法更新头像来源。", + "refreshFailed": "无法刷新 OIDC 图片。", + "rateLimited": "请稍等片刻再更改个人头像。" } } diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 795a4364..acabe3ad 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 { diff --git a/frontend/src/routes/profile/+page.svelte b/frontend/src/routes/profile/+page.svelte index c364d914..e6baacd3 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 { Calendar, Check, Copy, ExternalLink, ImagePlus, Info, 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,113 @@ 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 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.uploadFailed') }; + return; + } + if (selected.size > appConfig.avatar_max_upload_mb * 1024 * 1024) { + avatarMessage = { type: 'error', text: $_('avatar.uploadFailed') }; + 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 +844,99 @@ 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 +1575,7 @@ import ShareLinkDialog from '#lib/components/ShareLinkDialog.svelte';