Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions app/core/image_validation.py
Original file line number Diff line number Diff line change
@@ -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,
)
56 changes: 54 additions & 2 deletions app/router/mobile/auth.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -202,7 +204,57 @@ 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,
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}"'},
)
113 changes: 5 additions & 108 deletions app/router/mobile/enrollement.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -18,14 +14,14 @@
AuditEventType,
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,
)
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
Expand All @@ -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,
)


Expand Down
6 changes: 3 additions & 3 deletions app/schema/response/mobile/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -22,7 +23,6 @@ def from_user(cls, user: User) -> "AuditActorSchema":
display_name=user.display_name,
)


class AuditEventSchema(BaseModel):
id: UUID
event_type: AuditEventType
Expand Down
2 changes: 2 additions & 0 deletions app/schema/response/mobile/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class SessionSchema(BaseModel):
class UserSchema(BaseModel):
id: uuid.UUID
email: str
name: str | None
avatar_url: str | None

class MeResponse(BaseModel):
user: UserSchema
Expand Down
Loading