File size: 2,178 Bytes
9b5157d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
"""
FastAPI dependency functions and shared async utilities.
"""
import asyncio
import io
import logging
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from typing import Callable, TypeVar

from fastapi import Depends, UploadFile, File
from PIL import Image

from app.core.config import settings
from app.core.exceptions import ImageDecodeError, ImageTooLargeError, InvalidMimeTypeError

logger = logging.getLogger(__name__)

# Dedicated thread pool for blocking CV operations (MediaPipe, OpenCV, DeepFace).
# These are CPU-bound and must not run on the event loop thread.
cv_executor = ThreadPoolExecutor(
    max_workers=settings.CV_THREAD_POOL_SIZE,
    thread_name_prefix="cv-worker",
)

T = TypeVar("T")

ALLOWED_MIME_TYPES = {"image/jpeg", "image/png", "image/webp"}


async def run_in_cv_executor(fn: Callable[..., T], *args) -> T:
    """Run a blocking CV function in the dedicated thread pool."""
    loop = asyncio.get_event_loop()
    return await loop.run_in_executor(cv_executor, partial(fn, *args))


async def validated_image_bytes(
    file: UploadFile = File(..., description="Selfie image (JPEG/PNG/WebP)"),
) -> bytes:
    """
    FastAPI dependency that validates MIME type, file size, and image dimensions.
    Returns raw bytes ready for CV processing.
    """
    content_type = file.content_type or ""
    if content_type not in ALLOWED_MIME_TYPES:
        raise InvalidMimeTypeError(received=content_type, allowed=ALLOWED_MIME_TYPES)

    file_bytes = await file.read()

    if len(file_bytes) > settings.MAX_IMAGE_BYTES:
        raise ImageTooLargeError(max_mb=settings.MAX_IMAGE_SIZE_MB)

    # Decompression bomb guard — check pixel dimensions without fully decoding
    try:
        img = Image.open(io.BytesIO(file_bytes))
        w, h = img.size
        if w > settings.MAX_IMAGE_DIMENSION or h > settings.MAX_IMAGE_DIMENSION:
            raise ImageTooLargeError(
                detail=f"Image dimensions {w}×{h} exceed maximum {settings.MAX_IMAGE_DIMENSION}px per side."
            )
    except ImageTooLargeError:
        raise
    except Exception:
        raise ImageDecodeError()

    return file_bytes