From 4e7290c23b2efb91ff184a3334a35dd6832fea5e Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 4 Jun 2026 01:48:25 +0100 Subject: [PATCH 01/16] fix: file type detection --- app/router/mobile/enrollement.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index 1a5f652..153edc6 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -1,5 +1,5 @@ from typing import Annotated, List - +import filetype from fastapi import APIRouter, File, UploadFile, Depends from app.container import Container, get_container @@ -50,12 +50,14 @@ async def enroll_face( image_payloads: list[FaceImagePayload] = [] for file in files: - if file.content_type not in IMAGE_ALLOWED_TYPES: + contents = await file.read() + + kind = filetype.guess(contents) + if kind is None or kind.mime not in IMAGE_ALLOWED_TYPES: raise AppException.image_format_error( - f"File {file.filename} has unsupported format {file.content_type}" + f"File {file.filename} is not a valid image. Allowed types: {', '.join(IMAGE_ALLOWED_TYPES)}" ) - - contents = await file.read() + if len(contents) > MAX_IMAGE_SIZE: raise AppException.bad_request( f"File {file.filename} exceeds maximum size of {MAX_IMAGE_SIZE} bytes" From dfe1e73fa52dfc82cd5f55afc940636066644dce Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 4 Jun 2026 01:52:05 +0100 Subject: [PATCH 02/16] fix: read image by chunks and exit early on size limit --- app/router/mobile/enrollement.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index 153edc6..c086393 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -50,7 +50,7 @@ async def enroll_face( image_payloads: list[FaceImagePayload] = [] for file in files: - contents = await file.read() + contents = await read_limited(file, MAX_IMAGE_SIZE) kind = filetype.guess(contents) if kind is None or kind.mime not in IMAGE_ALLOWED_TYPES: @@ -75,3 +75,18 @@ async def enroll_face( user.user_id, image_payloads, ) + +async def read_limited(file: UploadFile, limit: int) -> bytes: + chunks = [] + 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 size of {limit} bytes" + ) + chunks.append(chunk) + return b"".join(chunks) \ No newline at end of file From 4426f6bbb1040763e6c2fd4e563e6daa744eb267 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 4 Jun 2026 02:25:22 +0100 Subject: [PATCH 03/16] fix: sanitise filename, use sniffed MIME --- app/core/constant.py | 2 ++ app/router/mobile/enrollement.py | 23 +++++++++++++---------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/app/core/constant.py b/app/core/constant.py index b1ffd28..aa672ee 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -50,5 +50,7 @@ class AuditEventType(str, Enum): GOOGLE_DRIVE_FILES_URL = "https://www.googleapis.com/drive/v3/files/{file_id}" MAX_IMAGE_SIZE = 5 * 1024 * 1024 +MIN_IMAGE_DIM = 64 +MAX_IMAGE_DIM = 4096 MIN_ENROLL_IMAGES = 3 MAX_ENROLL_IMAGES = 5 diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index c086393..8e3c1ac 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -1,3 +1,5 @@ +import re +import uuid from typing import Annotated, List import filetype from fastapi import APIRouter, File, UploadFile, Depends @@ -6,7 +8,6 @@ from app.deps.token_auth import MobileUserSchema, get_current_mobile_user from app.core.exceptions import AppException from app.core.constant import ( - DEFAULT_CONTENT_TYPE, IMAGE_ALLOWED_TYPES, MAX_ENROLL_IMAGES, MAX_IMAGE_SIZE, @@ -17,6 +18,13 @@ 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}" @router.post("/enroll") async def enroll_face( @@ -55,17 +63,12 @@ async def enroll_face( kind = filetype.guess(contents) if kind is None or kind.mime not in IMAGE_ALLOWED_TYPES: raise AppException.image_format_error( - f"File {file.filename} is not a valid image. Allowed types: {', '.join(IMAGE_ALLOWED_TYPES)}" - ) - - if len(contents) > MAX_IMAGE_SIZE: - raise AppException.bad_request( - f"File {file.filename} exceeds maximum size of {MAX_IMAGE_SIZE} bytes" + f"Unsupported format. Allowed types: {', '.join(IMAGE_ALLOWED_TYPES)}" ) payload: FaceImagePayload = FaceImagePayload( - filename=file.filename or "unknown", - content_type=file.content_type or DEFAULT_CONTENT_TYPE, + filename=_sanitise_filename(file.filename, kind.extension), + content_type=kind.mime, bytes=contents, ) @@ -85,7 +88,7 @@ async def read_limited(file: UploadFile, limit: int) -> bytes: break total += len(chunk) if total > limit: - raise AppException.bad_request( + raise AppException.image_size_error( f"File exceeds maximum size of {limit} bytes" ) chunks.append(chunk) From d5ef8b3e1cee2732e07ec7a3d12ca5a9968047a2 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 4 Jun 2026 02:27:30 +0100 Subject: [PATCH 04/16] fix: validate image dimensions to protect ML model from corrupt or extreme inputs --- app/router/mobile/enrollement.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index 8e3c1ac..2abe452 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -1,5 +1,7 @@ import re import uuid +from PIL import Image +from io import BytesIO from typing import Annotated, List import filetype from fastapi import APIRouter, File, UploadFile, Depends @@ -12,6 +14,8 @@ MAX_ENROLL_IMAGES, MAX_IMAGE_SIZE, MIN_ENROLL_IMAGES, + MIN_IMAGE_DIM, + MAX_IMAGE_DIM, ) from app.service.face_embedding import FaceImagePayload from db.generated.models import User @@ -26,6 +30,24 @@ def _sanitise_filename(raw: str | None, extension: str) -> str: name = name.lstrip(".")[:128] return f"{prefix}_{name}" +def _validate_dimensions(contents: bytes) -> None: + try: + img = Image.open(BytesIO(contents)) + img.verify() + w, h = img.size + except Exception: + raise AppException.image_format_error( + "File could not be decoded as a valid image" + ) + 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" + ) + @router.post("/enroll") async def enroll_face( files: Annotated[ @@ -65,6 +87,8 @@ async def enroll_face( raise AppException.image_format_error( f"Unsupported format. Allowed types: {', '.join(IMAGE_ALLOWED_TYPES)}" ) + + _validate_dimensions(contents) payload: FaceImagePayload = FaceImagePayload( filename=_sanitise_filename(file.filename, kind.extension), From 155bff5172f2edc1ac5749e1b746dc607c0454e7 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 4 Jun 2026 02:33:36 +0100 Subject: [PATCH 05/16] fix: add EnrollmentResponse schema --- app/router/mobile/enrollement.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index 2abe452..8d3c6c0 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -5,7 +5,7 @@ from typing import Annotated, List import filetype from fastapi import APIRouter, File, UploadFile, Depends - +from pydantic import BaseModel from app.container import Container, get_container from app.deps.token_auth import MobileUserSchema, get_current_mobile_user from app.core.exceptions import AppException @@ -20,6 +20,12 @@ from app.service.face_embedding import FaceImagePayload from db.generated.models import User +class EnrollmentResponse(BaseModel): + id: uuid.UUID + + class Config: + from_attributes = True + router = APIRouter() def _sanitise_filename(raw: str | None, extension: str) -> str: @@ -48,7 +54,8 @@ def _validate_dimensions(contents: bytes) -> None: f"Image too large — maximum {MAX_IMAGE_DIM}x{MAX_IMAGE_DIM} px" ) -@router.post("/enroll") +@router.post("/enroll", response_model=EnrollmentResponse) + async def enroll_face( files: Annotated[ List[UploadFile], @@ -98,10 +105,11 @@ async def enroll_face( image_payloads.append(payload) - return await container.auth_service.add_embbed_user( + updated_user = await container.auth_service.add_embbed_user( user.user_id, image_payloads, ) + return EnrollmentResponse.model_validate(updated_user) async def read_limited(file: UploadFile, limit: int) -> bytes: chunks = [] From 275f04cdf39ebeaaa48ea3c56ba38658443efc93 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 4 Jun 2026 02:35:17 +0100 Subject: [PATCH 06/16] fix: correct return type annotation to EnrollmentResponse --- app/router/mobile/enrollement.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index 8d3c6c0..af259f6 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -18,7 +18,7 @@ MAX_IMAGE_DIM, ) from app.service.face_embedding import FaceImagePayload -from db.generated.models import User + class EnrollmentResponse(BaseModel): id: uuid.UUID @@ -77,7 +77,7 @@ async def enroll_face( ], container: Container = Depends(get_container), user: MobileUserSchema = Depends(get_current_mobile_user), -) -> User: +) -> EnrollmentResponse: if not (MIN_ENROLL_IMAGES <= len(files) <= MAX_ENROLL_IMAGES): raise AppException.bad_request( From f834229c473022120bf40827a9eccece3cf87029 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 4 Jun 2026 02:38:10 +0100 Subject: [PATCH 07/16] fix: add per-user enrollment rate limit --- app/core/constant.py | 3 +++ app/router/mobile/enrollement.py | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/app/core/constant.py b/app/core/constant.py index aa672ee..c3fb518 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -54,3 +54,6 @@ class AuditEventType(str, Enum): MAX_IMAGE_DIM = 4096 MIN_ENROLL_IMAGES = 3 MAX_ENROLL_IMAGES = 5 + +ENROLL_RATE_LIMIT_MAX = 5 +ENROLL_RATE_LIMIT_WINDOW = 3600 \ No newline at end of file diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index af259f6..91f6d65 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -16,6 +16,8 @@ MIN_ENROLL_IMAGES, MIN_IMAGE_DIM, MAX_IMAGE_DIM, + ENROLL_RATE_LIMIT_MAX, + ENROLL_RATE_LIMIT_WINDOW, ) from app.service.face_embedding import FaceImagePayload @@ -78,6 +80,13 @@ async def enroll_face( container: Container = Depends(get_container), user: MobileUserSchema = Depends(get_current_mobile_user), ) -> EnrollmentResponse: + + await container.auth_service.check_rate_limit( + redis=container.redis, + key=f"rate:enroll:{user.user_id}", + max_requests=ENROLL_RATE_LIMIT_MAX, + window_seconds=ENROLL_RATE_LIMIT_WINDOW, + ) if not (MIN_ENROLL_IMAGES <= len(files) <= MAX_ENROLL_IMAGES): raise AppException.bad_request( From 9c676ab269bdcfbe9371b77732581f02d08c0323 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 4 Jun 2026 02:42:12 +0100 Subject: [PATCH 08/16] fix: catch unexpected errors and return clean 500 instead of raw traceback --- app/router/mobile/enrollement.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index 91f6d65..dfa4b70 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -114,11 +114,18 @@ async def enroll_face( image_payloads.append(payload) - updated_user = await container.auth_service.add_embbed_user( - user.user_id, - image_payloads, - ) - return EnrollmentResponse.model_validate(updated_user) + try: + updated_user = await container.auth_service.add_embbed_user( + user.user_id, + image_payloads, + ) + return EnrollmentResponse.model_validate(updated_user) + except AppException: + raise + except Exception: + raise AppException.internal_error( + "Enrollment failed due to an internal error" + ) async def read_limited(file: UploadFile, limit: int) -> bytes: chunks = [] From 3018a6a82e38e03a8a11beb015fbb2897de721ce Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 4 Jun 2026 02:42:39 +0100 Subject: [PATCH 09/16] chore: fix OpenAPI description for enroll endpoint --- app/router/mobile/enrollement.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index dfa4b70..fdfcf52 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -61,20 +61,13 @@ def _validate_dimensions(contents: bytes) -> None: async def enroll_face( files: Annotated[ List[UploadFile], - File( - description="Upload one or more face images", - openapi_examples={ - "single_file": { - "summary": "One file example", - "description": "Example of uploading one file", - "value": "example.jpg" - }, - "multiple_files": { - "summary": "Multiple files example", - "description": "Example of uploading multiple files", - "value": ["face1.png", "face2.png"] - }, - }, + File( + description=( + f"Between {MIN_ENROLL_IMAGES} and {MAX_ENROLL_IMAGES} face images " + f"(JPEG, PNG, HEIC, or HEIF). " + f"Each file must be under {MAX_IMAGE_SIZE // (1024 * 1024)} MB " + f"and at least {MIN_IMAGE_DIM}x{MIN_IMAGE_DIM} px." + ), ), ], container: Container = Depends(get_container), From 07e44efbd826d664e0d273b414b05cae105c7bf6 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 4 Jun 2026 03:17:21 +0100 Subject: [PATCH 10/16] fix : handle mypy and ruff errors --- app/core/constant.py | 2 +- app/router/mobile/enrollement.py | 135 ++++++++++++++++++------------- app/service/users.py | 19 +++-- 3 files changed, 94 insertions(+), 62 deletions(-) diff --git a/app/core/constant.py b/app/core/constant.py index c3fb518..55a65b5 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -56,4 +56,4 @@ class AuditEventType(str, Enum): MAX_ENROLL_IMAGES = 5 ENROLL_RATE_LIMIT_MAX = 5 -ENROLL_RATE_LIMIT_WINDOW = 3600 \ No newline at end of file +ENROLL_RATE_LIMIT_WINDOW = 3600 diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index fdfcf52..b0af757 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -1,35 +1,46 @@ import re import uuid -from PIL import Image from io import BytesIO from typing import Annotated, List -import filetype -from fastapi import APIRouter, File, UploadFile, Depends + +import filetype # type: ignore[import-untyped] +import pillow_heif # type: ignore[import-untyped] +from fastapi import APIRouter, Depends, File, UploadFile +from fastapi.concurrency import run_in_threadpool +from PIL import Image from pydantic import BaseModel + from app.container import Container, get_container -from app.deps.token_auth import MobileUserSchema, get_current_mobile_user -from app.core.exceptions import AppException from app.core.constant import ( + ENROLL_RATE_LIMIT_MAX, + ENROLL_RATE_LIMIT_WINDOW, IMAGE_ALLOWED_TYPES, MAX_ENROLL_IMAGES, MAX_IMAGE_SIZE, + MAX_IMAGE_DIM, MIN_ENROLL_IMAGES, MIN_IMAGE_DIM, - MAX_IMAGE_DIM, - ENROLL_RATE_LIMIT_MAX, - ENROLL_RATE_LIMIT_WINDOW, ) +from app.core.exceptions import AppException +from app.deps.token_auth import MobileUserSchema, get_current_mobile_user from app.service.face_embedding import FaceImagePayload +pillow_heif.register_heif_opener() + + +Image.MAX_IMAGE_PIXELS = MAX_IMAGE_DIM * MAX_IMAGE_DIM + + class EnrollmentResponse(BaseModel): id: uuid.UUID - - class Config: - from_attributes = True + + model_config = {"from_attributes": True} + router = APIRouter() + def _sanitise_filename(raw: str | None, extension: str) -> str: prefix = str(uuid.uuid4()) if not raw: @@ -38,15 +49,31 @@ def _sanitise_filename(raw: str | None, extension: str) -> str: name = name.lstrip(".")[:128] return f"{prefix}_{name}" + def _validate_dimensions(contents: bytes) -> None: + try: img = Image.open(BytesIO(contents)) - img.verify() w, h = img.size - except Exception: + 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" @@ -55,13 +82,31 @@ def _validate_dimensions(contents: bytes) -> None: raise AppException.bad_request( f"Image too large — maximum {MAX_IMAGE_DIM}x{MAX_IMAGE_DIM} px" ) - -@router.post("/enroll", response_model=EnrollmentResponse) + +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) + + +@router.post("/enroll", response_model=EnrollmentResponse) async def enroll_face( - files: Annotated[ + files: Annotated[ List[UploadFile], - File( + File( description=( f"Between {MIN_ENROLL_IMAGES} and {MAX_ENROLL_IMAGES} face images " f"(JPEG, PNG, HEIC, or HEIF). " @@ -73,8 +118,7 @@ async def enroll_face( container: Container = Depends(get_container), user: MobileUserSchema = Depends(get_current_mobile_user), ) -> EnrollmentResponse: - - await container.auth_service.check_rate_limit( + await container.auth_service.check_rate_limit( redis=container.redis, key=f"rate:enroll:{user.user_id}", max_requests=ENROLL_RATE_LIMIT_MAX, @@ -86,8 +130,8 @@ async def enroll_face( f"You must upload between {MIN_ENROLL_IMAGES} and {MAX_ENROLL_IMAGES} images for enrollment." ) - image_payloads: list[FaceImagePayload] = [] + for file in files: contents = await read_limited(file, MAX_IMAGE_SIZE) @@ -96,41 +140,24 @@ async def enroll_face( raise AppException.image_format_error( f"Unsupported format. Allowed types: {', '.join(IMAGE_ALLOWED_TYPES)}" ) - - _validate_dimensions(contents) - payload: FaceImagePayload = FaceImagePayload( - filename=_sanitise_filename(file.filename, kind.extension), - content_type=kind.mime, - bytes=contents, - ) - - image_payloads.append(payload) + await run_in_threadpool(_validate_dimensions, contents) - try: - updated_user = await container.auth_service.add_embbed_user( - user.user_id, - image_payloads, - ) - return EnrollmentResponse.model_validate(updated_user) - except AppException: - raise - except Exception: - raise AppException.internal_error( - "Enrollment failed due to an internal error" + image_payloads.append( + FaceImagePayload( + filename=_sanitise_filename(file.filename, kind.extension), + content_type=kind.mime, + bytes=contents, ) + ) -async def read_limited(file: UploadFile, limit: int) -> bytes: - chunks = [] - total = 0 - while True: - chunk = await file.read(65536) - if not chunk: - break - total += len(chunk) - if total > limit: - raise AppException.image_size_error( - f"File exceeds maximum size of {limit} bytes" - ) - chunks.append(chunk) - return b"".join(chunks) \ No newline at end of file + try: + updated_user = await container.auth_service.add_embbed_user( + user.user_id, + image_payloads, + ) + return EnrollmentResponse.model_validate(updated_user) + except Exception as e: + raise AppException.internal_error( + "Enrollment failed due to an internal error" + ) from e diff --git a/app/service/users.py b/app/service/users.py index 2237d8c..aab2891 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -216,7 +216,6 @@ async def _create_mobile_session( expiry = Get_expiry_time() logger.info("session_created session_id=%s user_id=%s", session.id, user_id) - # Populate Redis auth cache for fast-path validation await SessionService.cache_session_for_auth( redis=redis, session_id=session.id, @@ -290,6 +289,15 @@ async def add_embbed_user( ) -> User: logger.info("Generating face embeddings for user %s", user_id) + existing = await self.user_querier.get_user_by_id(id=user_id) + if not existing: + raise AppException.not_found("User not found") + if existing.face_embedding is not None: + raise AppException.conflict( + "User already has an active face enrollment. " + "Delete the existing enrollment before re-enrolling." + ) + averaging = await self.face_embedding_service.compute_average_embedding( image_payloads ) @@ -410,7 +418,6 @@ async def delete_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: session_key = constant.RedisKey.UserSessionByUser.value.format( user_id=user_id ) - # Best-effort: also invalidate the per-session MobileSessionCache. raw_session_id = await redis.get(session_key) if raw_session_id: try: @@ -431,15 +438,13 @@ async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: raise AppException.not_found("User not found") session_key = constant.RedisKey.UserSessionByUser.value.format(user_id=user_id) - # Best-effort: retrieve the session_id from UserSessionByUser cache to also - # invalidate the per-session MobileSessionCache entry. raw_session_id = await redis.get(session_key) if raw_session_id: try: session_id = uuid.UUID(raw_session_id) await SessionService.delete_session_cache(redis=redis, session_id=session_id) except (ValueError, Exception): - pass # non-blocking: session cache will expire naturally + pass await redis.delete(session_key) return user @@ -474,9 +479,9 @@ async def check_rate_limit( ) -> None: """Enforce rate limiting using Redis INCR + EXPIRE. - Increments a counter for ``key``. On the first increment the key + 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 + automatically. If the counter exceeds ``max_requests`` a 429 response is raised with a ``Retry-After`` header. """ current_count = await redis.incr(key) From f8dfb2b32bd834abd5252a84a0dd09ed3f8538e9 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Thu, 4 Jun 2026 03:34:52 +0100 Subject: [PATCH 11/16] fix: added missing dependencies --- pyproject.toml | 2 ++ uv.lock | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index cb5cd01..d615333 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,8 @@ dependencies = [ "firebase-admin>=6.8.0", "pywebpush>=2.3.0", "opencv-python>=4.13.0.92", + "filetype>=1.2.0", + "pillow-heif>=1.3.0", ] [tool.ruff] diff --git a/uv.lock b/uv.lock index afaadba..48310d2 100644 --- a/uv.lock +++ b/uv.lock @@ -305,6 +305,7 @@ dependencies = [ { name = "bcrypt" }, { name = "cryptography" }, { name = "fastapi", extra = ["standard"] }, + { name = "filetype" }, { name = "firebase-admin" }, { name = "greenlet" }, { name = "insightface" }, @@ -315,6 +316,7 @@ dependencies = [ { name = "opencv-python" }, { name = "opencv-python-headless" }, { name = "passlib", extra = ["bcrypt"] }, + { name = "pillow-heif" }, { name = "psycopg" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -342,6 +344,7 @@ requires-dist = [ { name = "bcrypt", specifier = "==4.3.0" }, { name = "cryptography", specifier = ">=46.0.5" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.135.1" }, + { name = "filetype", specifier = ">=1.2.0" }, { name = "firebase-admin", specifier = ">=6.8.0" }, { name = "greenlet", specifier = ">=3.3.2" }, { name = "insightface", specifier = ">=0.7.3" }, @@ -352,6 +355,7 @@ requires-dist = [ { name = "opencv-python", specifier = ">=4.13.0.92" }, { name = "opencv-python-headless", specifier = ">=4.13.0.92" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, + { name = "pillow-heif", specifier = ">=1.3.0" }, { name = "psycopg", specifier = ">=3.3.3" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, @@ -919,6 +923,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" }, ] +[[package]] +name = "filetype" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, +] + [[package]] name = "firebase-admin" version = "6.8.0" @@ -2318,6 +2331,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, ] +[[package]] +name = "pillow-heif" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/58/2df4fc42840633e01c97b75965cb1bc6e14425973b92382391650e97e4b7/pillow_heif-1.3.0.tar.gz", hash = "sha256:af8d2bda85e395677d5bb50d7bda3b5655c946cc95b913b5e7222fabacbb467f", size = 17133211, upload-time = "2026-02-27T12:21:36.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f7/e0b13500470421536fdfe01acfc4c56daccd3d23655605aa04cfb30cc58c/pillow_heif-1.3.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:079abbcaeb42ef0849a33f35c1a96ccd431feb56b242a0d4f8435a1c8ca02c7d", size = 4667382, upload-time = "2026-02-27T12:20:45.149Z" }, + { url = "https://files.pythonhosted.org/packages/a6/fb/beb62f26231e7c76d235e993d18b4ddb3d2d427e93539b8e6eb8dc188fa7/pillow_heif-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76c33f80ec111492642b98309db98516a7fba9677dcda9ec5fe9111b7e38d720", size = 3392733, upload-time = "2026-02-27T12:20:46.606Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/3d86e237b3a20c909f62a50e8cb3492ed6206675136d1ebddb168920261b/pillow_heif-1.3.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33b838d06e2fd730f806af5a76bfc4cd3de9d146d88d37572e40f7a4c4ff8221", size = 5844247, upload-time = "2026-02-27T12:20:49.669Z" }, + { url = "https://files.pythonhosted.org/packages/58/2a/826faf3df8c9ef9a19dc96bdfff34cf76f8b025540d5f931903d2c64f25c/pillow_heif-1.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f92b387af891cf5d98f52e79eeaf51ee7955a54fe2deeec12bfb7519e41464b5", size = 5578692, upload-time = "2026-02-27T12:20:51.219Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4e/59f74fede18e3e06f98a448488df2fa4e5de1c159419d47ca345a51a0da0/pillow_heif-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f9f73246836f93f99343cbc3052b61d212d27e59ddf40262d494a1e3e54af31a", size = 6885928, upload-time = "2026-02-27T12:20:52.934Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e4/bce83d2f4d703f418d252b509a6c9d6de52dc7c4eedcf6a286f871dea824/pillow_heif-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:84c3816742c2e49176e651895e73c555b9c3b0f3561d60230242f3be0c9d272b", size = 6511151, upload-time = "2026-02-27T12:20:54.236Z" }, + { url = "https://files.pythonhosted.org/packages/41/c2/87d433a9681c79e0926d8a113ea153d592ec49d1d7aa7278ee798bc490f2/pillow_heif-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:5f0db0bf49162fb1d73d13340a9576b3a2805bde026a9a40038bcc1a0878d710", size = 5483578, upload-time = "2026-02-27T12:20:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/81/c3/9effa6ab5c2c2ffb80228143c578a9a2a8e2f059dd9d067ec6ff6f6c89db/pillow_heif-1.3.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:641c50a064aa9ad6626a6b2b914b65855202f937d573d53838e344feb2e8c6d1", size = 4667379, upload-time = "2026-02-27T12:20:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/23/eb/b6b52e3655f366b95301f18aecd2d35487cace18d17134b80ad0f70cc1eb/pillow_heif-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9390dd7987887aa09779fbd88bbab715c732c9ad3a71d6707284035e3ca93379", size = 3392725, upload-time = "2026-02-27T12:20:59.52Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b3/b69610e9565fc8bcaf2303f412e857c0439d23cc18cf866c72a96ec6b2e6/pillow_heif-1.3.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e8444ccb330015e1db930207d269886e4b6c666121cd9e5fdad88735950b09f", size = 5844285, upload-time = "2026-02-27T12:21:00.771Z" }, + { url = "https://files.pythonhosted.org/packages/47/8c/be44f6dea425a9756ff418cb03f5ee75ed1c7dd1ff9bee1f3893b2b82da4/pillow_heif-1.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d30054ccc97ecbe5ee3fa486a505ccc33bfbb27f005ad624ddb4c17b80ddd57", size = 5578691, upload-time = "2026-02-27T12:21:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/5c/74/e12d49346a39e2204b408a835b31b2fd9a5d51f97ce3a6015cf22ca09a54/pillow_heif-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc1b9c9efdf8345d703118449ff69696d0827bdf28e3b52f82015f5714f7c23e", size = 6885923, upload-time = "2026-02-27T12:21:03.782Z" }, + { url = "https://files.pythonhosted.org/packages/80/a6/51c937a9433f5ae9c625b686ee338bdf0080a1661f7eb34daaf75424ee77/pillow_heif-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee26b2155721e7f5f7b10fa93ca2ad3be59547c5c5e5d9d50e6ea17531b81d60", size = 6511216, upload-time = "2026-02-27T12:21:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/63/0a/bb8435e127f75b434166022471bbabf11c8c1fc3d48c8595fd6ab36c2785/pillow_heif-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:17ecbbadfe10ea12a65c1c12354dc1ed8ae1e5d1b7092ea753641b029f7d6f9e", size = 5483570, upload-time = "2026-02-27T12:21:06.566Z" }, + { url = "https://files.pythonhosted.org/packages/3e/17/aa056f8edb71396dd1131abcd0c6feab00097ceec89a12fc62d2dbc3ccf5/pillow_heif-1.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8267a73d3b2d07a47a96428bd8cd4c406e1637a94f29d4c16ce08b31b8e50a07", size = 4667395, upload-time = "2026-02-27T12:21:08.16Z" }, + { url = "https://files.pythonhosted.org/packages/19/1f/da50ccd271a2878d17df359301dc2f7a79ec1cbb6e92c19ccc8c6219d497/pillow_heif-1.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:36bbea7679467caa3a154db11c04f1ca2fa8591e886f06f40f7831c14b58d771", size = 3392800, upload-time = "2026-02-27T12:21:09.668Z" }, + { url = "https://files.pythonhosted.org/packages/11/bc/1f89d927c1293cf283bc5d0ae6735d268d2de9749aa6fb94342ec838a457/pillow_heif-1.3.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea3a4b2de4b6c63407af72afdac901616807c6e6a030fe77851d227bca3727a", size = 5844547, upload-time = "2026-02-27T12:21:10.826Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/d781b23f8bff125c8dd8da63d928a35e38f2b727e89582a1fd323664e968/pillow_heif-1.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05149bd26b08dae5af7a389af6db13cef4f12c7871db73d84e40a1f3c83b0142", size = 5578827, upload-time = "2026-02-27T12:21:12.06Z" }, + { url = "https://files.pythonhosted.org/packages/2a/98/8dcdaafcf9bd8b26ed0569dc93653dc20a06faef7bfbdd4ba05c091c5b60/pillow_heif-1.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f8b7a50058fc3152f42b68aa2b30601249f61aa5c6c27876af076785c7051fd9", size = 6886088, upload-time = "2026-02-27T12:21:13.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/26/93f3c8bfffb7e8fe0244bf86117235c49c23980e61320e7484c03ac836e2/pillow_heif-1.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:edb3ef437e8841475db14721f0529e600bb55c41b549ad1794a0831e28f33bac", size = 6511291, upload-time = "2026-02-27T12:21:15.354Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f9/a8c72619ec212eb2612730fa2b3068e2d4b59e0a0957c2e8418aa4cff59e/pillow_heif-1.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:bdd6695d5be0d98ae0e9a5f88fe26f1a6eca0a5b6d43d0a92a97f89fea5842f7", size = 5640949, upload-time = "2026-02-27T12:21:16.647Z" }, + { url = "https://files.pythonhosted.org/packages/b7/31/92ce30e1ada892e18a03042bd5a8414f655304a78a36790e657f14265fed/pillow_heif-1.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:65c5d05cb7f5e1eadbe9c605ae3a4dd3ef953adb33e7d809d5fb56f8a6753588", size = 4668365, upload-time = "2026-02-27T12:21:18.004Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2b/789fa3c82063a780e84de667771b8ec30bc328511855f15a83a3c77011ec/pillow_heif-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dc177fbdf598770cad4afa99c082a30b9d090e60c39656904338717803ae59b2", size = 3393554, upload-time = "2026-02-27T12:21:19.642Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a4/4f8075f03c1d06d7afd674e263a3f57b7b24130c39b1544555b3b03ed369/pillow_heif-1.3.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71f88d180547bb5112b56310c8c5e338d8358320a402c80afabc6b2f39eadddb", size = 5849609, upload-time = "2026-02-27T12:21:20.953Z" }, + { url = "https://files.pythonhosted.org/packages/0d/08/e33a10bc84ade1b4ec56bdc765735bbfd452513e33537df68107edc0eb86/pillow_heif-1.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9acee893186bdde6140d30a7dc6d7c928e4ad3007989764f6e54a7a517faa332", size = 5582931, upload-time = "2026-02-27T12:21:22.571Z" }, + { url = "https://files.pythonhosted.org/packages/cb/45/6afc0f29701e0c9b911b33a35760ae6e2c581fc49b431dcce22ed18abfba/pillow_heif-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7cf893689132bec18f0c55a505da9ebf3a8feb33dd354fe2ac050f20f4f862e0", size = 6891268, upload-time = "2026-02-27T12:21:24.021Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0a/0d6a69f76f277692555d0e687dbf3e31d03cf76fffa3ced1fea51a18c481/pillow_heif-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:54404c9b6f0323114527579f54cc966b47206f99d943e47d73e1091ab0b9d2ba", size = 6515405, upload-time = "2026-02-27T12:21:25.336Z" }, + { url = "https://files.pythonhosted.org/packages/39/21/716856a36c1cc30a8f1354bf6423f251b1f50851af3e13b9cf084a13d2e3/pillow_heif-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:18c7c35a9d98ed9eaaf2db601ee43425ebccc698801df9c008aa04e00756a22e", size = 5641581, upload-time = "2026-02-27T12:21:26.642Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" From e8f84bc3e62b8717badd4a968c0419530c6e5764 Mon Sep 17 00:00:00 2001 From: Tyjfre-j Date: Sat, 6 Jun 2026 03:45:35 +0100 Subject: [PATCH 12/16] fix(enrollment): add row lock protection --- app/service/users.py | 15 +++++++++++++-- db/generated/user.py | 24 ++++++++++++++++++++++++ db/queries/user.sql | 6 ++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/app/service/users.py b/app/service/users.py index aab2891..95f0988 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta, timezone import uuid +from collections.abc import AsyncIterable from typing import Optional from sqlalchemy.exc import SQLAlchemyError @@ -285,7 +286,7 @@ async def logout( async def add_embbed_user( self, user_id: uuid.UUID, - image_payloads: list[FaceImagePayload], + image_payloads: AsyncIterable[FaceImagePayload], ) -> User: logger.info("Generating face embeddings for user %s", user_id) @@ -298,10 +299,20 @@ async def add_embbed_user( "Delete the existing enrollment before re-enrolling." ) - averaging = await self.face_embedding_service.compute_average_embedding( + averaging = await self.face_embedding_service.compute_average_embedding_stream( image_payloads ) vector_literal = "[" + ", ".join(str(x) for x in averaging) + "]" + + locked_existing = await self.user_querier.get_user_by_id_for_update(id=user_id) + if not locked_existing: + raise AppException.not_found("User not found") + if locked_existing.face_embedding is not None: + raise AppException.conflict( + "User already has an active face enrollment. " + "Delete the existing enrollment before re-enrolling." + ) + user = await self.user_querier.set_user_embedding( dollar_1=vector_literal, id=user_id, diff --git a/db/generated/user.py b/db/generated/user.py index b236857..674d4b4 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -55,6 +55,14 @@ class FindClosestUserByEmbeddingRow: """ +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 +FROM users +WHERE id = :p1 +FOR UPDATE +""" + + LIST_USERS = """-- name: list_users \\:many SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked FROM users @@ -179,6 +187,22 @@ async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: blocked=row[8], ) + async def get_user_by_id_for_update(self, *, id: uuid.UUID) -> Optional[models.User]: + row = (await self._conn.execute(sqlalchemy.text(GET_USER_BY_ID_FOR_UPDATE), {"p1": 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], + ) + async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.User]: result = await self._conn.stream(sqlalchemy.text(LIST_USERS), {"p1": limit, "p2": offset}) async for row in result: diff --git a/db/queries/user.sql b/db/queries/user.sql index a46577b..fc906dd 100644 --- a/db/queries/user.sql +++ b/db/queries/user.sql @@ -8,6 +8,12 @@ SELECT * FROM users WHERE id = $1; +-- name: GetUserByIdForUpdate :one +SELECT * +FROM users +WHERE id = $1 +FOR UPDATE; + -- name: GetUserByEmail :one SELECT * FROM users From ae0cca6a5fd4cc96d9ece0aa60a4b77e25040617 Mon Sep 17 00:00:00 2001 From: Tyjfre-j Date: Sat, 6 Jun 2026 03:45:44 +0100 Subject: [PATCH 13/16] fix(enrollment): stream validated uploads to embedding --- app/service/face_embedding.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/app/service/face_embedding.py b/app/service/face_embedding.py index e5333a0..a571f19 100644 --- a/app/service/face_embedding.py +++ b/app/service/face_embedding.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from collections.abc import AsyncIterable, AsyncIterator from dataclasses import dataclass from typing import List, Literal, Optional, Sequence, Tuple, TypedDict @@ -134,20 +135,27 @@ async def compute_average_embedding( self, payloads: Sequence[FaceImagePayload], ) -> list[float]: + async def iter_payloads() -> AsyncIterator[FaceImagePayload]: + for payload in payloads: + yield payload - if not payloads: - raise AppException.bad_request( - "At least one image is required for enrollment" - ) + return await self.compute_average_embedding_stream(iter_payloads()) + + async def compute_average_embedding_stream( + self, + payloads: AsyncIterable[FaceImagePayload], + ) -> list[float]: + has_payload = False embeddings: list[np.ndarray] = [] - for payload in payloads: + async for payload in payloads: + has_payload = True image = self._decode_image(payload) image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # Single detection pass — model.get() already returns embeddings - faces: list[FaceStub] = await asyncio.to_thread( # type: ignore + faces: list[FaceStub] = await asyncio.to_thread( # type: ignore self.face_embedding.model.get, image_rgb # type: ignore ) @@ -165,6 +173,11 @@ async def compute_average_embedding( embeddings.append(face.embedding.astype(np.float32)) + if not has_payload: + raise AppException.bad_request( + "At least one image is required for enrollment" + ) + stacked = np.stack(embeddings, axis=0) averaged = np.mean(stacked, axis=0) From fc2ddbc5ce5517ea45bfb5a0553a6b98494c4e39 Mon Sep 17 00:00:00 2001 From: Tyjfre-j Date: Sat, 6 Jun 2026 03:46:41 +0100 Subject: [PATCH 14/16] fix(enrollment): add audit and lock constants --- app/core/constant.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/core/constant.py b/app/core/constant.py index 55a65b5..847c119 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -22,6 +22,7 @@ class AuditEventType(str, Enum): USER_SIGNUP = "user.signup" USER_LOGIN = "user.login" USER_LOGOUT = "user.logout" + FACE_ENROLLMENT_ATTEMPT = "face_enrollment.attempt" UPLOAD_REQUEST_CREATED = "upload_request.created" UPLOAD_REQUEST_APPROVED = "upload_request.approved" UPLOAD_REQUEST_REJECTED = "upload_request.rejected" @@ -57,3 +58,4 @@ class AuditEventType(str, Enum): ENROLL_RATE_LIMIT_MAX = 5 ENROLL_RATE_LIMIT_WINDOW = 3600 +ENROLL_IN_PROGRESS_TTL_SECONDS = 300 From ea70fe093170a08a26a858f435b44a7049308ab1 Mon Sep 17 00:00:00 2001 From: Tyjfre-j Date: Sat, 6 Jun 2026 03:47:52 +0100 Subject: [PATCH 15/16] fix(enrollment): add redis lock and audit events --- app/router/mobile/enrollement.py | 100 +++++++++++++++++++++---------- 1 file changed, 70 insertions(+), 30 deletions(-) diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index b0af757..90ce4ff 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -1,17 +1,21 @@ 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, UploadFile +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 from app.container import Container, get_container from app.core.constant import ( + ENROLL_IN_PROGRESS_TTL_SECONDS, + AuditEventType, ENROLL_RATE_LIMIT_MAX, ENROLL_RATE_LIMIT_WINDOW, IMAGE_ALLOWED_TYPES, @@ -22,6 +26,7 @@ MIN_IMAGE_DIM, ) from app.core.exceptions import AppException +from app.core.logger import logger from app.deps.token_auth import MobileUserSchema, get_current_mobile_user from app.service.face_embedding import FaceImagePayload @@ -118,46 +123,81 @@ async def enroll_face( container: Container = Depends(get_container), user: MobileUserSchema = Depends(get_current_mobile_user), ) -> EnrollmentResponse: - await container.auth_service.check_rate_limit( - redis=container.redis, - key=f"rate:enroll:{user.user_id}", - max_requests=ENROLL_RATE_LIMIT_MAX, - window_seconds=ENROLL_RATE_LIMIT_WINDOW, - ) - - if not (MIN_ENROLL_IMAGES <= len(files) <= MAX_ENROLL_IMAGES): - raise AppException.bad_request( - f"You must upload between {MIN_ENROLL_IMAGES} and {MAX_ENROLL_IMAGES} images for enrollment." - ) + start_time = time.perf_counter() + image_count = len(files) + lock_key: str | None = None + lock_value: str | None = None + lock_acquired = False - image_payloads: list[FaceImagePayload] = [] + async def image_payloads() -> AsyncIterator[FaceImagePayload]: + for file in files: + yield await _build_face_image_payload(file) - for file in files: - contents = await read_limited(file, MAX_IMAGE_SIZE) + try: + await container.auth_service.check_rate_limit( + redis=container.redis, + key=f"rate:enroll:{user.user_id}", + max_requests=ENROLL_RATE_LIMIT_MAX, + window_seconds=ENROLL_RATE_LIMIT_WINDOW, + ) - 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)}" + if not (MIN_ENROLL_IMAGES <= image_count <= MAX_ENROLL_IMAGES): + raise AppException.bad_request( + f"You must upload between {MIN_ENROLL_IMAGES} and " + f"{MAX_ENROLL_IMAGES} images for enrollment." ) - await run_in_threadpool(_validate_dimensions, contents) - - image_payloads.append( - FaceImagePayload( - filename=_sanitise_filename(file.filename, kind.extension), - content_type=kind.mime, - bytes=contents, - ) + lock_key = _enrollment_lock_key(user.user_id) + lock_value = str(uuid.uuid4()) + lock_acquired = await container.redis.set( + lock_key, + lock_value, + expire=ENROLL_IN_PROGRESS_TTL_SECONDS, + nx=True, ) + if not lock_acquired: + raise AppException.conflict( + "Enrollment already in progress. Please wait for it to finish." + ) - try: updated_user = await container.auth_service.add_embbed_user( user.user_id, - image_payloads, + image_payloads(), + ) + await _record_enrollment_audit( + container=container, + user_id=user.user_id, + image_count=image_count, + outcome="success", + duration_ms=int((time.perf_counter() - start_time) * 1000), ) return EnrollmentResponse.model_validate(updated_user) + except HTTPException as exc: + await _record_enrollment_audit( + container=container, + user_id=user.user_id, + image_count=image_count, + outcome="failure", + duration_ms=int((time.perf_counter() - start_time) * 1000), + error_category=f"http_{exc.status_code}", + ) + raise except Exception as e: + await _record_enrollment_audit( + container=container, + user_id=user.user_id, + image_count=image_count, + outcome="failure", + duration_ms=int((time.perf_counter() - start_time) * 1000), + error_category="unexpected_error", + ) raise AppException.internal_error( "Enrollment failed due to an internal error" ) from e + finally: + if lock_acquired and lock_key is not None and lock_value is not None: + await _release_enrollment_lock( + container=container, + lock_key=lock_key, + lock_value=lock_value, + ) From 7d45354c06182739c8ee021c873c0718bec341a9 Mon Sep 17 00:00:00 2001 From: Tyjfre-j Date: Sat, 6 Jun 2026 03:48:09 +0100 Subject: [PATCH 16/16] fix(enrollment): add header prechecks and audit helpers --- app/router/mobile/enrollement.py | 93 ++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index 90ce4ff..6ebb13d 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -107,6 +107,99 @@ async def read_limited(file: UploadFile, limit: int) -> bytes: 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) + + return FaceImagePayload( + filename=_sanitise_filename(file.filename, kind.extension), + content_type=kind.mime, + bytes=contents, + ) + + +async def _record_enrollment_audit( + *, + container: Container, + user_id: uuid.UUID, + image_count: int, + outcome: str, + duration_ms: int, + error_category: str | None = None, +) -> None: + metadata: dict[str, object] = { + "endpoint": "enroll", + "image_count": image_count, + "outcome": outcome, + "duration_ms": duration_ms, + } + if error_category is not None: + metadata["error_category"] = error_category + + try: + await container.audit_service.create_record( + event_type=AuditEventType.FACE_ENROLLMENT_ATTEMPT, + user_id=user_id, + metadata=metadata, + ) + except Exception as exc: + logger.warning( + "Failed to publish enrollment audit for user %s: %s", user_id, exc + ) + + +def _enrollment_lock_key(user_id: uuid.UUID) -> str: + return f"enroll:in_progress:{user_id}" + + +async def _release_enrollment_lock( + *, + container: Container, + lock_key: str, + lock_value: str, +) -> None: + try: + if await container.redis.get(lock_key) == lock_value: + await container.redis.delete(lock_key) + except Exception as exc: + logger.warning("Failed to release enrollment lock %s: %s", lock_key, exc) + + @router.post("/enroll", response_model=EnrollmentResponse) async def enroll_face( files: Annotated[