From 573fc51c7da645e3bd3214a364550623c13be33a Mon Sep 17 00:00:00 2001 From: Tyjfre-j Date: Fri, 3 Jul 2026 00:58:48 +0100 Subject: [PATCH 1/7] fix: pinned compose project name in mobile quick start --- mobile-quickstart/docker-compose.mobile.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/mobile-quickstart/docker-compose.mobile.yml b/mobile-quickstart/docker-compose.mobile.yml index bc65f66..c6474a8 100644 --- a/mobile-quickstart/docker-compose.mobile.yml +++ b/mobile-quickstart/docker-compose.mobile.yml @@ -1,3 +1,4 @@ +name: multai-mobile-test services: postgres: image: pgvector/pgvector:pg16 From 0326da80c3fa9e31a157354ad9856bbd891193de Mon Sep 17 00:00:00 2001 From: Tyjfre-j Date: Fri, 3 Jul 2026 01:45:44 +0100 Subject: [PATCH 2/7] fix: return user display on user/auth:me --- app/router/mobile/auth.py | 2 +- app/schema/response/mobile/auth.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index 01f6c4a..1257d72 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -202,7 +202,7 @@ async def get_me( ) return MeResponse( - user=UserSchema(id=user.id, email=user.email), + user=UserSchema(id=user.id, email=user.email, name=user.display_name), devices=device_list, sessions=session_schema, ) diff --git a/app/schema/response/mobile/auth.py b/app/schema/response/mobile/auth.py index e03a074..97da6d9 100644 --- a/app/schema/response/mobile/auth.py +++ b/app/schema/response/mobile/auth.py @@ -18,6 +18,7 @@ class SessionSchema(BaseModel): class UserSchema(BaseModel): id: uuid.UUID email: str + name: str | None class MeResponse(BaseModel): user: UserSchema From 89c5b7b8c380cfdfa2b8ada4ef01330e5b8a31dd Mon Sep 17 00:00:00 2001 From: Tyjfre-j Date: Fri, 3 Jul 2026 01:57:57 +0100 Subject: [PATCH 3/7] feat(users): add avatar_key column and UpdateUserAvatar query --- db/generated/models.py | 1 + db/generated/user.py | 53 +++++++++++++++---- db/queries/user.sql | 7 +++ .../sql/down/add_avatar_key_to_users.sql | 2 + migrations/sql/up/add_avatar_key_to_users.sql | 2 + .../f0fa13623f6c_add_avatar_key_to_users.py | 24 +++++++++ 6 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 migrations/sql/down/add_avatar_key_to_users.sql create mode 100644 migrations/sql/up/add_avatar_key_to_users.sql create mode 100644 migrations/versions/f0fa13623f6c_add_avatar_key_to_users.py diff --git a/db/generated/models.py b/db/generated/models.py index 07418cb..9908c54 100644 --- a/db/generated/models.py +++ b/db/generated/models.py @@ -245,6 +245,7 @@ class User: face_embedding: Optional[Any] deleted_at: Optional[datetime.datetime] blocked: bool + avatar_key: Optional[str] @dataclasses.dataclass() diff --git a/db/generated/user.py b/db/generated/user.py index d0ab815..e922f86 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -15,7 +15,7 @@ CREATE_USER = """-- name: create_user \\:one INSERT INTO users (email, hashed_password) VALUES (:p1, :p2) -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key """ @@ -42,21 +42,21 @@ class FindClosestUserByEmbeddingRow: GET_USER_BY_EMAIL = """-- name: get_user_by_email \\:one -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key FROM users WHERE email = :p1 """ GET_USER_BY_ID = """-- name: get_user_by_id \\:one -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key FROM users WHERE id = :p1 """ GET_USER_BY_ID_FOR_UPDATE = """-- name: get_user_by_id_for_update \\:one -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key FROM users WHERE id = :p1 FOR UPDATE @@ -64,7 +64,7 @@ class FindClosestUserByEmbeddingRow: LIST_USERS = """-- name: list_users \\:many -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key FROM users ORDER BY created_at DESC LIMIT :p1 OFFSET :p2 @@ -90,7 +90,7 @@ class ListUsersWithEmbeddingRow: SET blocked = :p1, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key """ @@ -99,7 +99,7 @@ class ListUsersWithEmbeddingRow: SET face_embedding = :p1\\:\\:vector, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key """ @@ -110,7 +110,16 @@ class ListUsersWithEmbeddingRow: blocked = COALESCE(:p3, blocked), updated_at = NOW() WHERE id = :p4 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +""" + + +UPDATE_USER_AVATAR = """-- name: update_user_avatar \\:one +UPDATE users +SET avatar_key = :p1, + updated_at = NOW() +WHERE id = :p2 +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key """ @@ -119,7 +128,7 @@ class ListUsersWithEmbeddingRow: SET hashed_password = :p1, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key """ @@ -141,6 +150,7 @@ async def create_user(self, *, email: str, hashed_password: Optional[str]) -> Op face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], ) async def delete_user(self, *, id: uuid.UUID) -> None: @@ -169,6 +179,7 @@ async def get_user_by_email(self, *, email: str) -> Optional[models.User]: face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], ) async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: @@ -185,6 +196,7 @@ async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], ) async def get_user_by_id_for_update(self, *, id: uuid.UUID) -> Optional[models.User]: @@ -201,6 +213,7 @@ async def get_user_by_id_for_update(self, *, id: uuid.UUID) -> Optional[models.U face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], ) async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.User]: @@ -216,6 +229,7 @@ async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.U face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], ) async def list_users_with_embedding(self) -> AsyncIterator[ListUsersWithEmbeddingRow]: @@ -240,6 +254,7 @@ async def set_user_blocked(self, *, blocked: bool, id: uuid.UUID) -> Optional[mo face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], ) async def set_user_embedding(self, *, dollar_1: Any, id: uuid.UUID) -> Optional[models.User]: @@ -256,6 +271,7 @@ async def set_user_embedding(self, *, dollar_1: Any, id: uuid.UUID) -> Optional[ face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], ) async def update_user(self, *, email: str, display_name: Optional[str], blocked: bool, id: uuid.UUID) -> Optional[models.User]: @@ -277,6 +293,24 @@ async def update_user(self, *, email: str, display_name: Optional[str], blocked: face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], + ) + + async def update_user_avatar(self, *, avatar_key: Optional[str], id: uuid.UUID) -> Optional[models.User]: + row = (await self._conn.execute(sqlalchemy.text(UPDATE_USER_AVATAR), {"p1": avatar_key, "p2": id})).first() + if row is None: + return None + return models.User( + id=row[0], + email=row[1], + hashed_password=row[2], + created_at=row[3], + updated_at=row[4], + display_name=row[5], + face_embedding=row[6], + deleted_at=row[7], + blocked=row[8], + avatar_key=row[9], ) async def update_user_password(self, *, hashed_password: Optional[str], id: uuid.UUID) -> Optional[models.User]: @@ -293,4 +327,5 @@ async def update_user_password(self, *, hashed_password: Optional[str], id: uuid face_embedding=row[6], deleted_at=row[7], blocked=row[8], + avatar_key=row[9], ) diff --git a/db/queries/user.sql b/db/queries/user.sql index fc906dd..61c7c59 100644 --- a/db/queries/user.sql +++ b/db/queries/user.sql @@ -26,6 +26,13 @@ SET hashed_password = $1, WHERE id = $2 RETURNING *; +-- name: UpdateUserAvatar :one +UPDATE users +SET avatar_key = $1, + updated_at = NOW() +WHERE id = $2 +RETURNING *; + -- name: UpdateUser :one UPDATE users SET email = COALESCE($1, email), diff --git a/migrations/sql/down/add_avatar_key_to_users.sql b/migrations/sql/down/add_avatar_key_to_users.sql new file mode 100644 index 0000000..b708389 --- /dev/null +++ b/migrations/sql/down/add_avatar_key_to_users.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.users +DROP COLUMN avatar_key; \ No newline at end of file diff --git a/migrations/sql/up/add_avatar_key_to_users.sql b/migrations/sql/up/add_avatar_key_to_users.sql new file mode 100644 index 0000000..dd8d7e7 --- /dev/null +++ b/migrations/sql/up/add_avatar_key_to_users.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.users +ADD COLUMN avatar_key character varying(255) DEFAULT NULL; \ No newline at end of file diff --git a/migrations/versions/f0fa13623f6c_add_avatar_key_to_users.py b/migrations/versions/f0fa13623f6c_add_avatar_key_to_users.py new file mode 100644 index 0000000..a79910c --- /dev/null +++ b/migrations/versions/f0fa13623f6c_add_avatar_key_to_users.py @@ -0,0 +1,24 @@ +"""add_avatar_key_to_users + +Revision ID: f0fa13623f6c +Revises: f3a1b7c9d201 +Create Date: 2026-07-03 01:46:31.410873 + +""" +from typing import Sequence, Union + +from migrations.helper import run_sql_down, run_sql_up + +# revision identifiers, used by Alembic. +revision: str = 'f0fa13623f6c' +down_revision: Union[str, Sequence[str], None] = 'f3a1b7c9d201' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + run_sql_up("add_avatar_key_to_users") + + +def downgrade() -> None: + run_sql_down("add_avatar_key_to_users") \ No newline at end of file From b8b2204914083078e20ed3486043908ed94fa165 Mon Sep 17 00:00:00 2001 From: Tyjfre-j Date: Fri, 3 Jul 2026 02:09:30 +0100 Subject: [PATCH 4/7] refactor(images): extract shared image validation from enrollment --- app/core/image_validation.py | 131 +++++++++++++++++++++++++++++++ app/router/mobile/enrollement.py | 113 ++------------------------ 2 files changed, 136 insertions(+), 108 deletions(-) create mode 100644 app/core/image_validation.py diff --git a/app/core/image_validation.py b/app/core/image_validation.py new file mode 100644 index 0000000..2258ab9 --- /dev/null +++ b/app/core/image_validation.py @@ -0,0 +1,131 @@ +import re +import uuid +from dataclasses import dataclass +from io import BytesIO + +import filetype # type: ignore[import-untyped] +from fastapi import UploadFile +from fastapi.concurrency import run_in_threadpool +from PIL import Image + +from app.core.constant import ( + IMAGE_ALLOWED_TYPES, + MAX_IMAGE_DIM, + MAX_IMAGE_SIZE, + MIN_IMAGE_DIM, +) +from app.core.exceptions import AppException + + +@dataclass +class ImagePayload: + filename: str + content_type: str + bytes: bytes + + +def sanitise_filename(raw: str | None, extension: str) -> str: + prefix = str(uuid.uuid4()) + if not raw: + return f"{prefix}.{extension}" + name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", raw) + name = name.lstrip(".")[:128] + return f"{prefix}_{name}" + + +def validate_dimensions(contents: bytes) -> None: + try: + img = Image.open(BytesIO(contents)) + w, h = img.size + except Exception as e: + raise AppException.image_format_error( + "File could not be decoded as a valid image" + ) from e + + max_pixels = Image.MAX_IMAGE_PIXELS + if max_pixels is not None and w * h > max_pixels: + raise AppException.bad_request( + f"Image exceeds maximum allowed resolution of {max_pixels} total pixels." + ) + + try: + img.load() + except Exception as e: + raise AppException.image_format_error( + "File contains corrupted or incomplete pixel data" + ) from e + + if w < MIN_IMAGE_DIM or h < MIN_IMAGE_DIM: + raise AppException.bad_request( + f"Image too small — minimum {MIN_IMAGE_DIM}x{MIN_IMAGE_DIM} px" + ) + if w > MAX_IMAGE_DIM or h > MAX_IMAGE_DIM: + raise AppException.bad_request( + f"Image too large — maximum {MAX_IMAGE_DIM}x{MAX_IMAGE_DIM} px" + ) + + +async def read_limited(file: UploadFile, limit: int) -> bytes: + chunks: list[bytes] = [] + total = 0 + while True: + chunk = await file.read(65536) + if not chunk: + break + total += len(chunk) + if total > limit: + raise AppException.bad_request( + f"File exceeds maximum allowed size of {limit} bytes" + ) + chunks.append(chunk) + + await file.seek(0) + return b"".join(chunks) + + +def precheck_upload_headers(file: UploadFile) -> None: + content_type = file.content_type + if not content_type: + raise AppException.image_format_error("Missing image Content-Type header") + + normalized_content_type = content_type.split(";", maxsplit=1)[0].strip().lower() + if normalized_content_type not in IMAGE_ALLOWED_TYPES: + allowed = ", ".join(IMAGE_ALLOWED_TYPES) + raise AppException.image_format_error( + f"Unsupported Content-Type header. Allowed types: {allowed}" + ) + + content_length = file.headers.get("content-length") + if content_length is None: + return + + try: + declared_size = int(content_length) + except ValueError as exc: + raise AppException.bad_request("Invalid image Content-Length header") from exc + + if declared_size > MAX_IMAGE_SIZE: + raise AppException.bad_request( + f"File exceeds maximum allowed size of {MAX_IMAGE_SIZE} bytes" + ) + + +async def build_image_payload( + file: UploadFile, *, max_size: int = MAX_IMAGE_SIZE +) -> ImagePayload: + precheck_upload_headers(file) + contents = await read_limited(file, max_size) + + kind = filetype.guess(contents) + if kind is None or kind.mime not in IMAGE_ALLOWED_TYPES: + raise AppException.image_format_error( + f"Unsupported format. Allowed types: {', '.join(IMAGE_ALLOWED_TYPES)}" + ) + + await run_in_threadpool(validate_dimensions, contents) + + return ImagePayload( + filename=sanitise_filename(file.filename, kind.extension), + content_type=kind.mime, + bytes=contents, + ) \ No newline at end of file diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index 6ebb13d..f8e2ac1 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -1,14 +1,10 @@ -import re import time import uuid from collections.abc import AsyncIterator -from io import BytesIO from typing import Annotated, List -import filetype # type: ignore[import-untyped] import pillow_heif # type: ignore[import-untyped] from fastapi import APIRouter, Depends, File, HTTPException, UploadFile -from fastapi.concurrency import run_in_threadpool from PIL import Image from pydantic import BaseModel @@ -18,7 +14,6 @@ AuditEventType, ENROLL_RATE_LIMIT_MAX, ENROLL_RATE_LIMIT_WINDOW, - IMAGE_ALLOWED_TYPES, MAX_ENROLL_IMAGES, MAX_IMAGE_SIZE, MAX_IMAGE_DIM, @@ -26,6 +21,7 @@ MIN_IMAGE_DIM, ) from app.core.exceptions import AppException +from app.core.image_validation import build_image_payload from app.core.logger import logger from app.deps.token_auth import MobileUserSchema, get_current_mobile_user from app.service.face_embedding import FaceImagePayload @@ -45,111 +41,12 @@ class EnrollmentResponse(BaseModel): router = APIRouter() - -def _sanitise_filename(raw: str | None, extension: str) -> str: - prefix = str(uuid.uuid4()) - if not raw: - return f"{prefix}.{extension}" - name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", raw) - name = name.lstrip(".")[:128] - return f"{prefix}_{name}" - - -def _validate_dimensions(contents: bytes) -> None: - - try: - img = Image.open(BytesIO(contents)) - w, h = img.size - except Exception as e: - raise AppException.image_format_error( - "File could not be decoded as a valid image" - ) from e - - max_pixels = Image.MAX_IMAGE_PIXELS - - if max_pixels is not None and w * h > max_pixels: - raise AppException.bad_request( - f"Image exceeds maximum allowed resolution of {max_pixels} total pixels." - ) - - try: - img.load() - except Exception as e: - raise AppException.image_format_error( - "File contains corrupted or incomplete pixel data" - ) from e - - if w < MIN_IMAGE_DIM or h < MIN_IMAGE_DIM: - raise AppException.bad_request( - f"Image too small — minimum {MIN_IMAGE_DIM}x{MIN_IMAGE_DIM} px" - ) - if w > MAX_IMAGE_DIM or h > MAX_IMAGE_DIM: - raise AppException.bad_request( - f"Image too large — maximum {MAX_IMAGE_DIM}x{MAX_IMAGE_DIM} px" - ) - - -async def read_limited(file: UploadFile, limit: int) -> bytes: - chunks: list[bytes] = [] - total = 0 - while True: - chunk = await file.read(65536) - if not chunk: - break - total += len(chunk) - if total > limit: - raise AppException.bad_request( - f"File exceeds maximum allowed size of {limit} bytes" - ) - chunks.append(chunk) - - await file.seek(0) - return b"".join(chunks) - - -def _precheck_upload_headers(file: UploadFile) -> None: - content_type = file.content_type - if not content_type: - raise AppException.image_format_error("Missing image Content-Type header") - - normalized_content_type = content_type.split(";", maxsplit=1)[0].strip().lower() - if normalized_content_type not in IMAGE_ALLOWED_TYPES: - allowed = ", ".join(IMAGE_ALLOWED_TYPES) - raise AppException.image_format_error( - f"Unsupported Content-Type header. Allowed types: {allowed}" - ) - - content_length = file.headers.get("content-length") - if content_length is None: - return - - try: - declared_size = int(content_length) - except ValueError as exc: - raise AppException.bad_request("Invalid image Content-Length header") from exc - - if declared_size > MAX_IMAGE_SIZE: - raise AppException.bad_request( - f"File exceeds maximum allowed size of {MAX_IMAGE_SIZE} bytes" - ) - - async def _build_face_image_payload(file: UploadFile) -> FaceImagePayload: - _precheck_upload_headers(file) - contents = await read_limited(file, MAX_IMAGE_SIZE) - - kind = filetype.guess(contents) - if kind is None or kind.mime not in IMAGE_ALLOWED_TYPES: - raise AppException.image_format_error( - f"Unsupported format. Allowed types: {', '.join(IMAGE_ALLOWED_TYPES)}" - ) - - await run_in_threadpool(_validate_dimensions, contents) - + payload = await build_image_payload(file) return FaceImagePayload( - filename=_sanitise_filename(file.filename, kind.extension), - content_type=kind.mime, - bytes=contents, + filename=payload.filename, + content_type=payload.content_type, + bytes=payload.bytes, ) From 0aa486a2e6f628b79ecf197070ba1256f939ee1a Mon Sep 17 00:00:00 2001 From: Tyjfre-j Date: Fri, 3 Jul 2026 03:51:39 +0100 Subject: [PATCH 5/7] feat: added avatar services --- app/service/users.py | 53 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/app/service/users.py b/app/service/users.py index 0ce7ca6..d2232c4 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -3,6 +3,7 @@ from collections.abc import AsyncIterable from typing import Optional +from fastapi import HTTPException from sqlalchemy.exc import SQLAlchemyError from app.core.exceptions import AppException, DBException @@ -17,7 +18,7 @@ from app.core import constant from app.core.config import settings from app.infra.redis import RedisClient - +from app.infra.minio import Bucket, IMAGES_BUCKET_NAME from app.schema.request.mobile.auth import ( MobileAuthBaseRequest, MobileLoginRequest, @@ -512,7 +513,47 @@ async def update_user( except Exception as exc: logger.error("Failed to update user: %s", exc) raise DBException.handle(exc) + + async def update_avatar(self, *, user_id: uuid.UUID, avatar_key: str) -> User: + try: + user = await self.user_querier.update_user_avatar( + avatar_key=avatar_key, + id=user_id, + ) + if not user: + raise AppException.not_found("User not found") + return user + except Exception as exc: + logger.error("Failed to update avatar for user %s: %s", user_id, exc) + raise DBException.handle(exc) + async def upload_avatar_bytes( + self, *, avatar_key: str, data: bytes, content_type: str, filename: str + ) -> None: + try: + bucket = Bucket(IMAGES_BUCKET_NAME, "avatars") + await bucket.put_bytes( + data=data, + object_name=avatar_key, + content_type=content_type, + filename=filename, + ) + except Exception as exc: + raise AppException.storage_error("Failed to upload avatar image") from exc + + async def get_avatar_bytes(self, *, user_id: uuid.UUID) -> tuple[bytes, str, str]: + user = await self.get_user(user_id=user_id) + if not user.avatar_key: + raise AppException.not_found("No avatar set") + + bucket = Bucket(IMAGES_BUCKET_NAME, "avatars") + try: + return await bucket.get(user.avatar_key) + except HTTPException: + raise + except Exception as exc: + raise AppException.storage_error("Failed to retrieve avatar image") from exc + async def delete_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: try: existing = await self.user_querier.get_user_by_id(id=user_id) @@ -581,13 +622,7 @@ async def check_rate_limit( max_requests: int, window_seconds: int, ) -> None: - """Enforce rate limiting using Redis INCR + EXPIRE. - - Increments a counter for ``key``. On the first increment the key - is given a TTL of ``window_seconds`` so the window resets - automatically. If the counter exceeds ``max_requests`` a 429 - response is raised with a ``Retry-After`` header. - """ + """Enforce rate limiting using Redis INCR + EXPIRE.""" current_count = await redis.incr(key) if current_count == 1: await redis.expire(key, window_seconds) @@ -595,4 +630,4 @@ async def check_rate_limit( raise AppException.too_many_requests( "Too many requests. Please try again later.", retry_after=window_seconds, - ) + ) \ No newline at end of file From df03e0dc362c96ff2368526cbdaddea435d07d07 Mon Sep 17 00:00:00 2001 From: Tyjfre-j Date: Fri, 3 Jul 2026 07:11:59 +0100 Subject: [PATCH 6/7] feat(users): add avatar upload/serve endpoints --- app/core/image_validation.py | 2 +- app/router/mobile/auth.py | 56 ++++++++++++++++++++++++++++-- app/schema/response/mobile/auth.py | 1 + app/service/users.py | 15 +++++--- 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/app/core/image_validation.py b/app/core/image_validation.py index 2258ab9..7e427c7 100644 --- a/app/core/image_validation.py +++ b/app/core/image_validation.py @@ -128,4 +128,4 @@ async def build_image_payload( filename=sanitise_filename(file.filename, kind.extension), content_type=kind.mime, bytes=contents, - ) \ No newline at end of file + ) diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index 1257d72..767d126 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -1,6 +1,8 @@ from typing import Optional -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, Request, UploadFile +from fastapi.responses import Response +from app.core.image_validation import build_image_payload from uuid import UUID from app.container import get_container, Container @@ -202,7 +204,57 @@ async def get_me( ) return MeResponse( - user=UserSchema(id=user.id, email=user.email, name=user.display_name), + user=UserSchema( + id=user.id, + email=user.email, + name=user.display_name, + avatar_url="/user/auth/me/avatar/image" if user.avatar_key else None, + ), devices=device_list, sessions=session_schema, ) + +@router.post("/me/avatar", response_model=UserSchema) +async def upload_avatar( + file: UploadFile, + current_user: MobileUserSchema = Depends(get_current_mobile_user), + container: Container = Depends(get_container), +) -> UserSchema: + payload = await build_image_payload(file) + object_name = str(current_user.user_id) + + await container.auth_service.upload_avatar_bytes( + avatar_key=object_name, + data=payload.bytes, + content_type=payload.content_type, + filename=payload.filename, + ) + try: + user = await container.auth_service.update_avatar( + user_id=current_user.user_id, avatar_key=object_name, + ) + except Exception: + await container.auth_service.delete_avatar_bytes(avatar_key=object_name) + raise + + return UserSchema( + id=user.id, + email=user.email, + name=user.display_name, + avatar_url="/user/auth/me/avatar/image", + ) + + +@router.get("/me/avatar/image") +async def get_avatar_image( + current_user: MobileUserSchema = Depends(get_current_mobile_user), + container: Container = Depends(get_container), +) -> Response: + data, filename, content_type = await container.auth_service.get_avatar_bytes( + user_id=current_user.user_id, + ) + return Response( + content=data, + media_type=content_type, + headers={"Content-Disposition": f'inline; filename="{filename}"'}, + ) \ No newline at end of file diff --git a/app/schema/response/mobile/auth.py b/app/schema/response/mobile/auth.py index 97da6d9..d00d26a 100644 --- a/app/schema/response/mobile/auth.py +++ b/app/schema/response/mobile/auth.py @@ -19,6 +19,7 @@ class UserSchema(BaseModel): id: uuid.UUID email: str name: str | None + avatar_url: str | None class MeResponse(BaseModel): user: UserSchema diff --git a/app/service/users.py b/app/service/users.py index d2232c4..9c83aae 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -3,7 +3,7 @@ from collections.abc import AsyncIterable from typing import Optional -from fastapi import HTTPException +from fastapi import HTTPException from sqlalchemy.exc import SQLAlchemyError from app.core.exceptions import AppException, DBException @@ -513,7 +513,7 @@ async def update_user( except Exception as exc: logger.error("Failed to update user: %s", exc) raise DBException.handle(exc) - + async def update_avatar(self, *, user_id: uuid.UUID, avatar_key: str) -> User: try: user = await self.user_querier.update_user_avatar( @@ -545,7 +545,7 @@ async def get_avatar_bytes(self, *, user_id: uuid.UUID) -> tuple[bytes, str, str user = await self.get_user(user_id=user_id) if not user.avatar_key: raise AppException.not_found("No avatar set") - + bucket = Bucket(IMAGES_BUCKET_NAME, "avatars") try: return await bucket.get(user.avatar_key) @@ -554,6 +554,13 @@ async def get_avatar_bytes(self, *, user_id: uuid.UUID) -> tuple[bytes, str, str except Exception as exc: raise AppException.storage_error("Failed to retrieve avatar image") from exc + async def delete_avatar_bytes(self, *, avatar_key: str) -> None: + try: + bucket = Bucket(IMAGES_BUCKET_NAME, "avatars") + await bucket.delete(avatar_key) + except Exception as exc: + logger.warning("Failed to clean up orphaned avatar %s: %s", avatar_key, exc) + async def delete_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: try: existing = await self.user_querier.get_user_by_id(id=user_id) @@ -630,4 +637,4 @@ async def check_rate_limit( raise AppException.too_many_requests( "Too many requests. Please try again later.", retry_after=window_seconds, - ) \ No newline at end of file + ) From 99d19bd9263bd766132163462b2ce97beab44f39 Mon Sep 17 00:00:00 2001 From: Tyjfre-j Date: Fri, 3 Jul 2026 07:18:20 +0100 Subject: [PATCH 7/7] refactor(audit): decouple AuditActorSchema from UserSchema --- app/router/mobile/auth.py | 2 +- app/schema/response/mobile/audit.py | 6 +++--- app/schema/response/web/audit.py | 6 +++--- app/service/users.py | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index 767d126..8da3672 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -257,4 +257,4 @@ async def get_avatar_image( content=data, media_type=content_type, headers={"Content-Disposition": f'inline; filename="{filename}"'}, - ) \ No newline at end of file + ) diff --git a/app/schema/response/mobile/audit.py b/app/schema/response/mobile/audit.py index 0119653..76983b2 100644 --- a/app/schema/response/mobile/audit.py +++ b/app/schema/response/mobile/audit.py @@ -8,10 +8,11 @@ from db.generated.models import AuditEvent, User from app.core.constant import AuditEventType -from app.schema.response.mobile.auth import UserSchema -class AuditActorSchema(UserSchema): +class AuditActorSchema(BaseModel): + id: UUID + email: str display_name: str | None @classmethod @@ -22,7 +23,6 @@ def from_user(cls, user: User) -> "AuditActorSchema": display_name=user.display_name, ) - class AuditEventSchema(BaseModel): id: UUID event_type: AuditEventType diff --git a/app/schema/response/web/audit.py b/app/schema/response/web/audit.py index 0119653..76983b2 100644 --- a/app/schema/response/web/audit.py +++ b/app/schema/response/web/audit.py @@ -8,10 +8,11 @@ from db.generated.models import AuditEvent, User from app.core.constant import AuditEventType -from app.schema.response.mobile.auth import UserSchema -class AuditActorSchema(UserSchema): +class AuditActorSchema(BaseModel): + id: UUID + email: str display_name: str | None @classmethod @@ -22,7 +23,6 @@ def from_user(cls, user: User) -> "AuditActorSchema": display_name=user.display_name, ) - class AuditEventSchema(BaseModel): id: UUID event_type: AuditEventType diff --git a/app/service/users.py b/app/service/users.py index 9c83aae..8ea7bcc 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -553,7 +553,7 @@ async def get_avatar_bytes(self, *, user_id: uuid.UUID) -> tuple[bytes, str, str raise except Exception as exc: raise AppException.storage_error("Failed to retrieve avatar image") from exc - + async def delete_avatar_bytes(self, *, avatar_key: str) -> None: try: bucket = Bucket(IMAGES_BUCKET_NAME, "avatars")