Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
081fc82
chore: save tests_backup locally
ademboukabes Jun 23, 2026
83138be
test(core): consolidate test suite, fix E2E auth flow, and update AI …
ademboukabes Jul 1, 2026
c9a7338
style: auto-fix ruff linting errors
ademboukabes Jul 1, 2026
f070bda
test(integration): fix mock for face embedding stream
ademboukabes Jul 1, 2026
205c7cb
style: fix remaining manual ruff errors
ademboukabes Jul 1, 2026
e8e45fa
chore: exclude tests and scripts from mypy
ademboukabes Jul 1, 2026
c697829
fix: pinned compose project name in mobile quick start
Tyjfre-j Jul 2, 2026
a62af52
fix: return user display on user/auth:me
Tyjfre-j Jul 3, 2026
3401071
feat(users): add avatar_key column and UpdateUserAvatar query
Tyjfre-j Jul 3, 2026
029e12a
refactor(images): extract shared image validation from enrollment
Tyjfre-j Jul 3, 2026
7f67c46
feat: added avatar services
Tyjfre-j Jul 3, 2026
275f937
feat(users): add avatar upload/serve endpoints
Tyjfre-j Jul 3, 2026
1219010
refactor(audit): decouple AuditActorSchema from UserSchema
Tyjfre-j Jul 3, 2026
275a281
ci: add docker build test to workflow
ademboukabes Jul 3, 2026
46e7021
Fix PR review bugs and failing tests
ademboukabes Jul 4, 2026
a46f47f
style: fix ruff whitespace warnings
ademboukabes Jul 4, 2026
c496768
ci: add automated tests job with postgres and redis
ademboukabes Jul 4, 2026
d83498a
fix: add missing pydantic env vars to ci
ademboukabes Jul 4, 2026
b939602
fix: skip E2E test_stats_endpoint in CI to prevent NATS timeout
ademboukabes Jul 4, 2026
f693d66
fix: add NATS and MinIO to CI services to prevent FastAPI lifespan ti…
ademboukabes Jul 4, 2026
c51c8f1
fix: removed dup imports
Tyjfre-j Jul 4, 2026
ac7ec04
fix: fixed stuff_user and evenParticipant naming issues
Tyjfre-j Jul 4, 2026
b0e3d14
fix(tests): scope sys.modules mocking in test_photo_worker.py so it d…
Tyjfre-j Jul 4, 2026
d4431aa
fix(redis): reset RedisClient._instance to None on close() to prevent…
Tyjfre-j Jul 4, 2026
739659e
fix(nats): wrap drain()/close() in try/finally so NatsClient singleto…
Tyjfre-j Jul 4, 2026
4c76daf
fix: respect MULTAI_RUN_E2E in stats endpoint tests
Tyjfre-j Jul 4, 2026
c5b7264
fix: ruff and mypy check
Tyjfre-j Jul 4, 2026
510b75a
fix added email worker to run-workers command
Tyjfre-j Jul 4, 2026
ef58ae8
fix: fixed env prefix in notification settings
Tyjfre-j Jul 4, 2026
7caee68
fix: fixed storage cleaner db connect
Tyjfre-j Jul 4, 2026
dee6f8a
fix: fix ruff
Tyjfre-j Jul 4, 2026
ea872d8
fix: added workers to mobile docker file
Tyjfre-j Jul 4, 2026
ee62217
fix: added workers to staging docker file
Tyjfre-j Jul 4, 2026
ab6643c
fix: updated mobile run docs
Tyjfre-j Jul 5, 2026
f1bd274
fix: removed dup opencv dep
Tyjfre-j Jul 5, 2026
3df3a69
feat(uploads): add UploadRequestGroupSummarySchema for lightweight gr…
Tyjfre-j Jul 5, 2026
a356c2c
fix(uploads): remove N+1 query pattern in list_groups
Tyjfre-j Jul 5, 2026
a6b5ef4
feat(uploads): use summary schema for GET /staff/uploads/groups
Tyjfre-j Jul 5, 2026
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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ totp_issuer=MultiAI

GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://127.0.0.1:8000/stuff/drive/callback
GOOGLE_REDIRECT_URI=http://127.0.0.1:8000/staff/drive/callback
GOOGLE_OAUTH_SCOPES=https://www.googleapis.com/auth/drive.readonly openid email profile
FACE_ENCRYPTION_KEY=hkbribvfirirbvivbibvib

Expand Down
4 changes: 3 additions & 1 deletion .env.staging.example
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,6 @@ GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://127.0.0.1:8000/staff/drive/callback
GOOGLE_OAUTH_SCOPES=https://www.googleapis.com/auth/drive.readonly openid email profile

FACE_ENCRYPTION_KEY=base64-encoded-32-byte-key
FACE_ENCRYPTION_KEY=base64-encoded-32-byte-key

FIREBASE_CREDENTIALS_PATH=/app/firebase-credentials.json
81 changes: 79 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,82 @@ jobs:
- name: Type Check
run: uv run mypy .

# - name: Run tests
# run: uv run pytest --maxfail=1 --disable-warnings
- name: Test Docker Build
run: docker build -t multai-back-test .


test:
name: Run Tests
runs-on: ubuntu-latest
needs: lint-and-typecheck
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
POSTGRES_PORT: 5432
POSTGRES_HOST: localhost
REDIS_HOST: localhost
REDIS_PORT: 6379
NATS_PORT: 4222
NATS_HOST: localhost
NATS_PASSWORD: dummy
NATS_USER: dummy
MINIO_API_PORT: 9000
MINIO_ROOT_USER: dummy
MINIO_ROOT_PASSWORD: dummy
MINIO_HOST: localhost
jwt_secret: test_secret
encryption_key: test_encryption_key
FACE_ENCRYPTION_KEY: test_face_encryption_key
FIREBASE_CREDENTIALS_PATH: dummy.json
services:
postgres:
image: pgvector/pgvector:pg16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7-alpine
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
nats:
image: nats:2.10-alpine
command: --user dummy --pass dummy
ports:
- 4222:4222
minio:
image: minio/minio:latest
env:
MINIO_ROOT_USER: dummy
MINIO_ROOT_PASSWORD: dummy
command: server /data
ports:
- 9000:9000

steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version-file: "pyproject.toml"
- name: Install dependencies
run: uv sync --locked --all-extras --dev
- name: Run Migrations
run: uv run alembic upgrade head
- name: Run Tests
run: uv run pytest tests/
11 changes: 8 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
.env
.env.staging
.env.mobile
__pycache__
db/schema.sql

.vscode/settings.json
multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json
firebase-credentials.json
db.txt

.venv
multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json

# Local Test & Debug Files
.coverage
dummy.json
test_output.log
*.log
6 changes: 2 additions & 4 deletions app/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,14 @@
from db.generated import session as session_queries
from db.generated import staff_drive_connections as staff_drive_queries
from db.generated import staff_notifications as staff_notification_queries
from db.generated import stuff_user as staff_user_queries
from db.generated import staff_user as staff_user_queries
from db.generated import upload_request_groups as upload_request_group_queries
from db.generated import upload_request_photos as upload_request_photo_queries
from db.generated import upload_requests as upload_request_queries
from db.generated import user as user_queries

from db.generated import events as event_queries
from db.generated import eventParticipant as participant_queries
from db.generated import stuff_user as staff_queries
from db.generated import event_participant as participant_queries
from db.generated import notifications as notification_queries
from db.generated import audit as audit_queries
from db.generated import stats as stats_queries
Expand Down Expand Up @@ -72,7 +71,6 @@ def __init__(
self.audit_querier = audit_queries.AsyncQuerier(conn)
self.event_querier = event_queries.AsyncQuerier(conn)
self.participant_querier = participant_queries.AsyncQuerier(conn)
self.staff_querier = staff_queries.AsyncQuerier(conn)
self.stats_querier = stats_queries.AsyncQuerier(conn)

# services
Expand Down
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,
)
11 changes: 6 additions & 5 deletions app/infra/nats.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,12 @@ async def connect(
@staticmethod
async def close() -> None:
if NatsClient._nc and not NatsClient._nc.is_closed:
await NatsClient._nc.drain()
await NatsClient._nc.close()
NatsClient._nc = None
NatsClient._js = None

try:
await NatsClient._nc.drain()
await NatsClient._nc.close()
finally:
NatsClient._nc = None
NatsClient._js = None

@staticmethod
async def publish(subject: NatsSubjects | str, message: bytes) -> None:
Expand Down
1 change: 1 addition & 0 deletions app/infra/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,4 @@ async def srem(self, key: RedisKey | str, *values: str) -> int:

async def close(self) -> None:
await self._client.close()
type(self)._instance = None
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}"'},
)
Loading