Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
65a0ad8
fix(photo-approval): resolve group photo lifecycle gaps
wailbentafat May 31, 2026
c02ce7d
test(photo-approval): add unit tests for group photo lifecycle gaps
ademboukabes Jun 2, 2026
f8818c3
style: fix trailing whitespaces in tests
ademboukabes Jun 2, 2026
4fb98a4
fix(test): resolve mypy type check errors and unused imports
ademboukabes Jun 2, 2026
26f0f00
fix: file type detection
Tyjfre-j Jun 4, 2026
f40ca1c
fix: read image by chunks and exit early on size limit
Tyjfre-j Jun 4, 2026
ca07b6f
fix: sanitise filename, use sniffed MIME
Tyjfre-j Jun 4, 2026
6969202
fix: validate image dimensions to protect ML model from corrupt or ex…
Tyjfre-j Jun 4, 2026
5eaf3e8
fix: add EnrollmentResponse schema
Tyjfre-j Jun 4, 2026
023c32b
fix: correct return type annotation to EnrollmentResponse
Tyjfre-j Jun 4, 2026
f8da9ea
fix: add per-user enrollment rate limit
Tyjfre-j Jun 4, 2026
1655f31
fix: catch unexpected errors and return clean 500 instead of raw trac…
Tyjfre-j Jun 4, 2026
5b2f793
chore: fix OpenAPI description for enroll endpoint
Tyjfre-j Jun 4, 2026
363aee4
fix : handle mypy and ruff errors
Tyjfre-j Jun 4, 2026
8ef4a51
fix: added missing dependencies
Tyjfre-j Jun 4, 2026
29a9851
fix(enrollment): add row lock protection
Tyjfre-j Jun 6, 2026
4a2dc06
fix(enrollment): stream validated uploads to embedding
Tyjfre-j Jun 6, 2026
5e4195b
fix(enrollment): add audit and lock constants
Tyjfre-j Jun 6, 2026
a4f6c4c
fix(enrollment): add redis lock and audit events
Tyjfre-j Jun 6, 2026
5a96f00
fix(enrollment): add header prechecks and audit helpers
Tyjfre-j Jun 6, 2026
2a300eb
test(e2e): add asynchronous AI pipeline E2E test
ademboukabes Jun 1, 2026
8ae7b4d
test(ai): add E2E tests, edge cases, load testing, and enable GPU inf…
ademboukabes Jun 2, 2026
111bca5
feat(security): implement redis rate limiting on mobile photo endpoint
ademboukabes Jun 6, 2026
10b78ef
test(security): fix auth security tests and resolve asyncio loop scop…
ademboukabes Jun 6, 2026
f35b0e8
test(e2e): refactor e2e tests and add robust ai pipeline test coverage
ademboukabes Jun 6, 2026
774a1a2
fix(security): resolve test leak in rate limiting tests
ademboukabes Jun 6, 2026
03212f6
perf(drive): use concurrent asyncio for faster google drive uploads
ademboukabes Jun 6, 2026
dd5be26
fix(core): address critical code review findings
ademboukabes Jun 9, 2026
60ee3f7
style(core): apply linter autofixes and mypy type ignores
ademboukabes Jun 9, 2026
c1bdc01
chore: remove tests from remote until finalized
ademboukabes Jun 9, 2026
fb8b47f
fix(main): await task cancellation on shutdown
ademboukabes Jun 23, 2026
e2c5ff2
perf(drive): limit max imports to 1000 and chunk asyncio tasks
ademboukabes Jun 23, 2026
ead1ac1
feat(auth): Implement async Email OTP Verification for mobile registr…
ademboukabes Jun 24, 2026
75f14d3
fix(auth): add return type for email sender and fix test mock
ademboukabes Jun 24, 2026
2e800fe
feat(auth): implement resend-otp route
ademboukabes Jun 24, 2026
765a015
feat(security): apply robust rate limiting to auth endpoints
ademboukabes Jun 24, 2026
ef4f766
feat(admin): implement dashboard stats api
ademboukabes Jun 30, 2026
0a4f7ac
feat: added seed script for db and backend run
Tyjfre-j Jun 29, 2026
2e9857e
fix: add .env.mobile.example, fix env_file references and readme
Tyjfre-j Jun 30, 2026
deef924
fix: added docker image pull and clean reset
Tyjfre-j Jun 30, 2026
37fe6eb
fix: corrected listuserphotos and and listeventphotos query
Tyjfre-j Jul 1, 2026
f46d5bb
fix: fixed mypy errors
Tyjfre-j Jul 1, 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
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,8 @@ FACE_ENCRYPTION_KEY=hkbribvfirirbvivbibvib
CORS_ORIGINS=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"]

# Firebase
FIREBASE_CREDENTIALS_PATH=path/to/firebase-credentials.json
FIREBASE_CREDENTIALS_PATH=path/to/firebase-credentials.json

# Resend Email Configuration
RESEND_API_KEY=
EMAIL_FROM=
9 changes: 8 additions & 1 deletion app/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@
from db.generated import stuff_user as staff_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

from app.service.event import EventService
from app.service.stats import StatsService
from app.worker.notification.notification_queue import NotificationQueue
from app.worker.notification.settings import NotifSetting

Expand Down Expand Up @@ -70,7 +73,7 @@ def __init__(
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
self.session_service = SessionService()
Expand Down Expand Up @@ -150,6 +153,10 @@ def __init__(
staff_drive_service=self.staff_drive_service,
)

self.stats_service = StatsService(
querier=self.stats_querier,
)

async def get_container(
conn: sqlalchemy.ext.asyncio.AsyncConnection = Depends(get_db),
) -> Container:
Expand Down
10 changes: 8 additions & 2 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ class Settings(BaseSettings):
POSTGRES_HOST: str = "localhost"
POSTGRES_PORT: int = 5432

PHOTO_APPROVAL_TIMEOUT_DAYS: int = 7

# Mobile auth/session defaults
MOBILE_SESSION_LIMIT: int = 3
MOBILE_SESSION_TTL_SECONDS: int = 180
Expand All @@ -57,8 +59,8 @@ class Settings(BaseSettings):

# Face embedding model
FACE_EMBEDDING_MODEL_NAME: str = "buffalo_l"
FACE_EMBEDDING_PROVIDERS: str = "CPUExecutionProvider"
FACE_EMBEDDING_CTX_ID: int = -1
FACE_EMBEDDING_PROVIDERS: str = "CUDAExecutionProvider,CPUExecutionProvider"
FACE_EMBEDDING_CTX_ID: int = 0
FACE_EMBEDDING_DET_WIDTH: int = 640
FACE_EMBEDDING_DET_HEIGHT: int = 640

Expand All @@ -73,6 +75,10 @@ class Settings(BaseSettings):
FACE_ENCRYPTION_KEY: str
FIREBASE_CREDENTIALS_PATH: str

# Resend Email Configuration
RESEND_API_KEY: str = ""
EMAIL_FROM: str = "onboarding@resend.dev"

model_config = SettingsConfigDict(
env_file=".env",
extra="ignore",
Expand Down
7 changes: 7 additions & 0 deletions app/core/constant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -50,5 +51,11 @@ 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

ENROLL_RATE_LIMIT_MAX = 5
ENROLL_RATE_LIMIT_WINDOW = 3600
ENROLL_IN_PROGRESS_TTL_SECONDS = 300
11 changes: 11 additions & 0 deletions app/deps/cookie_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,14 @@ async def require_multi_team_lead_staff(
current_staff_user: Annotated[StaffUser, Depends(get_current_staff_user)],
) -> StaffUser:
return ensure_multi_team_lead_staff(current_staff_user)


def ensure_admin_staff(current_staff_user: StaffUser) -> StaffUser:
if _role_value(current_staff_user.role) != StaffRole.ADMIN.value:
raise AppException.forbidden("Admin access required")
return current_staff_user

async def require_admin_staff(
current_staff_user: Annotated[StaffUser, Depends(get_current_staff_user)],
) -> StaffUser:
return ensure_admin_staff(current_staff_user)
36 changes: 36 additions & 0 deletions app/deps/rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
from fastapi import Request, HTTPException
from typing import Callable

from app.infra.redis import RedisClient
from app.core.config import settings

def _get_client_ip(request: Request) -> str:
if settings.TRUST_PROXY_HEADERS:
forwarded_for = request.headers.get("x-forwarded-for")
if forwarded_for:
return forwarded_for.split(",", maxsplit=1)[0].strip()
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
return request.client.host if request.client else "127.0.0.1"


def RateLimiter(requests: int, window: int) -> Callable:
async def _rate_limit_dependency(request: Request) -> None:
client_ip = _get_client_ip(request)
# For simplicity, IP based rate limit on the endpoint
path = request.url.path
key = f"rate_limit:{path}:{client_ip}"

redis = RedisClient.get_instance()

# Increment request count
current = await redis.incr(key)
if current == 1:
# Set expiry for the window if it's the first request
await redis.expire(key, window)

if current > requests:
raise HTTPException(status_code=429, detail="Too Many Requests")

return _rate_limit_dependency
61 changes: 61 additions & 0 deletions app/infra/email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import json
import urllib.request
import urllib.error
import asyncio
from app.core.config import settings
from app.core.logger import logger

class EmailSender:
@staticmethod
async def send_otp_email(to_email: str, otp: str) -> bool:
if not settings.RESEND_API_KEY:
logger.warning("RESEND_API_KEY is not set. Skipping email sending.")
# During development without an API key, just log the OTP
logger.info("MOCK EMAIL to %s: Your OTP is %s", to_email, otp)
return True

url = "https://api.resend.com/emails"
headers = {
"Authorization": f"Bearer {settings.RESEND_API_KEY}",
"Content-Type": "application/json",
"User-Agent": "multAI-Backend/1.0"
}

html_content = f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Bienvenue sur multAI !</h2>
<p>Voici votre code de vérification :</p>
<h1 style="background: #f4f4f4; padding: 10px; letter-spacing: 5px; text-align: center;">{otp}</h1>
<p>Ce code est valide pendant 10 minutes.</p>
</div>
"""

data = {
"from": settings.EMAIL_FROM,
"to": [to_email],
"subject": "Votre code de vérification multAI",
"html": html_content
}

req = urllib.request.Request(
url,
data=json.dumps(data).encode("utf-8"),
headers=headers,
method="POST"
)

def _send() -> bool:
try:
with urllib.request.urlopen(req, timeout=10) as response:
res_body = response.read()
logger.info("Email sent via Resend. Response: %s", res_body)
return True
except urllib.error.HTTPError as e:
err_body = e.read()
logger.error("Failed to send email via Resend: %s - %s", e.code, err_body)
return False
except Exception as e:
logger.error("Error sending email: %s", str(e))
return False

return await asyncio.to_thread(_send)
9 changes: 4 additions & 5 deletions app/infra/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,20 +59,19 @@ async def expire(self, key: RedisKey | str, seconds: int) -> bool:
return int(cast(int, result)) == 1

async def incr(self, key: RedisKey | str) -> int:
result = await self._client.incr(key)
return int(cast(int, result))
return await self._client.incr(key)


async def sadd(self, key: RedisKey | str, *values: str) -> int:
result = self._client.sadd(key, *values)
result = await self._client.sadd(key, *values) # type: ignore[misc]
return int(cast(int, result))

async def sismember(self, key: RedisKey | str, value: str) -> bool:
result = self._client.sismember(key, value)
result = await self._client.sismember(key, value) # type: ignore[misc]
return int(cast(int, result)) == 1

async def srem(self, key: RedisKey | str, *values: str) -> int:
result = self._client.srem(key, *values)
result = await self._client.srem(key, *values) # type: ignore[misc]
return int(cast(int, result))


Expand Down
17 changes: 17 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from app.core.config import settings
from app.infra.database import engine
from app.infra.minio import init_minio_client
from app.infra.nats import NatsClient
from app.infra.redis import RedisClient
Expand Down Expand Up @@ -48,6 +49,18 @@ async def dispatch(



async def _approval_expiry_loop() -> None:
while True:
await asyncio.sleep(3600)
try:
async with engine.begin() as conn:
from app.container import Container
container = Container(conn)
await container.photo_approval_service.expire_stale(settings.PHOTO_APPROVAL_TIMEOUT_DAYS)
except Exception as exc:
logger.warning("Approval expiry task failed: %s", exc)


MAX_RETRIES = 5
RETRY_DELAY = 2 # seconds
@asynccontextmanager
Expand Down Expand Up @@ -77,8 +90,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await NatsClient.connect()
get_face_embedding_service()

expiry_task = asyncio.create_task(_approval_expiry_loop())

yield

expiry_task.cancel()
await asyncio.gather(expiry_task, return_exceptions=True)
await RedisClient.get_instance().close()
await NatsClient.close()

Expand Down
35 changes: 30 additions & 5 deletions app/router/mobile/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@
from app.core.config import settings
from app.core.constant import AuditEventType
from app.deps.token_auth import MobileUserSchema, get_current_mobile_user
from app.deps.rate_limit import RateLimiter

from app.schema.request.mobile.auth import (
MobileLoginRequest,
MobileRegisterRequest,
RegisterVerifyRequest,
ResendOtpRequest,
RefreshTokenRequest,
UpdateDeviceTokenRequest,
InactivateDeviceRequest,
)
from app.schema.response.mobile.auth import MeResponse, DeviceSchema, MobileAuthResponse, SessionSchema, UserSchema
from app.schema.response.mobile.auth import MeResponse, DeviceSchema, MobileAuthResponse, SessionSchema, UserSchema, RegisterPendingResponse

router = APIRouter(prefix="/auth")

Expand All @@ -33,23 +36,45 @@ def _get_client_ip(request: Request) -> str | None:
return request.client.host if request.client else None


@router.post("/register", response_model=MobileAuthResponse)
@router.post("/register", response_model=RegisterPendingResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))])
async def mobile_register(
req: MobileRegisterRequest,
request: Request,
container: Container = Depends(get_container),
) -> MobileAuthResponse:
) -> RegisterPendingResponse:
client_ip = _get_client_ip(request)
result = await container.auth_service.mobile_register(container.redis, req, client_ip=client_ip)
return result


@router.post("/register/resend-otp", response_model=RegisterPendingResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))])
async def mobile_register_resend_otp(
req: ResendOtpRequest,
request: Request,
container: Container = Depends(get_container),
) -> RegisterPendingResponse:
client_ip = _get_client_ip(request)
result = await container.auth_service.mobile_register_resend_otp(container.redis, req.email, client_ip=client_ip)
return result


@router.post("/register/verify", response_model=MobileAuthResponse, dependencies=[Depends(RateLimiter(requests=10, window=60))])
async def mobile_register_verify(
req: RegisterVerifyRequest,
request: Request,
container: Container = Depends(get_container),
) -> MobileAuthResponse:
client_ip = _get_client_ip(request)
result = await container.auth_service.verify_mobile_register(container.redis, req, client_ip=client_ip)
await container.audit_service.create_record(
event_type=AuditEventType.USER_SIGNUP,
user_id=result.user_id,
metadata={"endpoint": "register"},
metadata={"endpoint": "register_verify"},
)
return result


@router.post("/login", response_model=MobileAuthResponse)
@router.post("/login", response_model=MobileAuthResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))])
async def mobile_login(
req: MobileLoginRequest,
request: Request,
Expand Down
Loading