Skip to content
Closed
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
17 changes: 17 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

class Settings(BaseSettings):
app_name: str = "multAI"
API_VERSION: str = "1.0.0"
environment: str = "dev"
debug: bool = True
CORS_ALLOW_ORIGINS: list[str] = ["*"]

# Redis
REDIS_PORT: int
Expand All @@ -28,6 +30,9 @@ class Settings(BaseSettings):
MINIO_HOST: str
MINIO_RETRY_ATTEMPTS: int = 3
MINIO_RETRY_BASE_SECONDS: float = 0.5
MINIO_INIT_MAX_RETRIES: int = 5
MINIO_INIT_RETRY_BASE_SECONDS: float = 2.0
MINIO_PART_SIZE_BYTES: int = 10 * 1024 * 1024

# PostgreSQL
POSTGRES_USER: str
Expand All @@ -40,6 +45,7 @@ class Settings(BaseSettings):
MOBILE_SESSION_LIMIT: int = 3
MOBILE_SESSION_TTL_SECONDS: int = 180
MOBILE_SESSION_DAYS: int = 7
SESSION_REDIS_TTL_SECONDS: int = 60 * 60 * 5
# Admin list defaults
ADMIN_USERS_DEFAULT_LIMIT: int = 20
ADMIN_USERS_MAX_LIMIT: int = 100
Expand All @@ -48,6 +54,9 @@ class Settings(BaseSettings):
jwt_algorithm: str = "HS256"
encryption_key: str
totp_issuer: str = "multAI"
ACCESS_TOKEN_EXPIRY_DEV_SECONDS: int = 60 * 60 * 24 * 7
ACCESS_TOKEN_EXPIRY_PROD_SECONDS: int = 60 * 60 * 24
STAFF_AUTH_COOKIE_MAX_AGE_SECONDS: int = 60 * 60 * 24 * 7

# Face embedding model
FACE_EMBEDDING_MODEL_NAME: str = "buffalo_l"
Expand Down Expand Up @@ -81,7 +90,15 @@ def _parse_debug(cls, value): # type: ignore[no-untyped-def]
return False
if lowered in {"true", "1", "yes"}:
return True
return value

@field_validator("CORS_ALLOW_ORIGINS", mode="before")
@classmethod
def _parse_cors_allow_origins(cls, value): # type: ignore[no-untyped-def]
if isinstance(value, str):
return [item.strip() for item in value.split(",") if item.strip()]
return value



settings = Settings() # type: ignore
36 changes: 10 additions & 26 deletions app/core/securite.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from typing import Any, Literal
import jwt
from passlib.context import CryptContext
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
import pyotp
from app.core.config import settings
from app.core.exceptions import AppException
Expand Down Expand Up @@ -30,19 +30,19 @@ def verify_password(password: str, hashed: str) -> bool:
return result


def Get_expiry_time() -> int:
def get_expiry_time() -> int:
if settings.environment == "dev":
expiry = 60 * 60 * 24 * 7
expiry = settings.ACCESS_TOKEN_EXPIRY_DEV_SECONDS
else:
expiry = 60 * 60 * 24
expiry = settings.ACCESS_TOKEN_EXPIRY_PROD_SECONDS
return expiry


def create_acces_mobile_token(session_id: str) -> str:
def create_access_mobile_token(session_id: str) -> str:
payload: dict[str, Any] = {
"session_id": session_id,
"exp": int(
(datetime.now(timezone.utc) + timedelta(seconds=Get_expiry_time())).timestamp()
(datetime.now(timezone.utc) + timedelta(seconds=get_expiry_time())).timestamp()
),
}
return jwt.encode(payload, key=settings.jwt_secret, algorithm=settings.jwt_algorithm)
Expand All @@ -62,7 +62,7 @@ def create_refresh_mobile_token(session_id: str) -> str:
payload: dict[str, Any] = {
"session_id": session_id,
"exp": int(
(datetime.now(timezone.utc) + timedelta(seconds=Get_expiry_time() * 4)).timestamp()
(datetime.now(timezone.utc) + timedelta(seconds=get_expiry_time() * 4)).timestamp()
),
}
return jwt.encode(payload, key=settings.jwt_secret, algorithm=settings.jwt_algorithm)
Expand Down Expand Up @@ -91,19 +91,6 @@ def verify_totp_token_with_window(secret: str, token: str, valid_window: int = 8
totp = pyotp.TOTP(secret)
return totp.verify(token, valid_window=valid_window)


def generate_Acces_token_stuff(user_id: str, role: str) -> str:
payload: dict[str, Any] = {
"user_id": user_id,
"role": role,
"exp": int(
(datetime.now(timezone.utc) + timedelta(seconds=Get_expiry_time())).timestamp()
),
}
return jwt.encode(payload, key=settings.jwt_secret, algorithm=settings.jwt_algorithm)



# class EmbeddingCrypto:
# _key: bytes = base64.b64decode(settings.FACE_ENCRYPTION_KEY)
# _aes: AESGCM = AESGCM(_key)
Expand All @@ -126,16 +113,13 @@ def generate_Acces_token_stuff(user_id: str, role: str) -> str:

# return np.frombuffer(data, dtype=np.float32)


class StaffJWTPayload(BaseModel):
sub: str
role: str
type: Literal["access", "refresh"]
exp: int

class Config:
frozen = True
# immutable class
model_config = ConfigDict(frozen=True)


def create_access_staff_token(staff_id: str, role: str) -> str:
Expand All @@ -147,7 +131,7 @@ def create_access_staff_token(staff_id: str, role: str) -> str:
role=role,
type="access",
exp=int(
(datetime.now(timezone.utc) + timedelta(seconds=Get_expiry_time())).timestamp()
(datetime.now(timezone.utc) + timedelta(seconds=get_expiry_time())).timestamp()
),
)
return jwt.encode(
Expand All @@ -166,7 +150,7 @@ def create_refresh_staff_token(staff_id: str, role: str) -> str:
role=role,
type="refresh",
exp=int(
(datetime.now(timezone.utc) + timedelta(seconds=Get_expiry_time() * 4)).timestamp()
(datetime.now(timezone.utc) + timedelta(seconds=get_expiry_time() * 4)).timestamp()
),
)
return jwt.encode(
Expand Down
4 changes: 0 additions & 4 deletions app/deps/cookie_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,9 @@
from db.generated.models import StaffRole, StaffUser
from app.core.securite import decode_staff_token



def _role_value(role: object) -> str:
return getattr(role, "value", str(role))



async def get_current_staff_user(
container: Annotated[Container, Depends(get_container)],
token: Annotated[str | None, Cookie(alias="access_token")] = None,
Expand Down
161 changes: 118 additions & 43 deletions app/infra/minio.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import asyncio
import io
import random
import string
import uuid
from typing import Awaitable, Callable, TypeVar
from fastapi import UploadFile
from miniopy_async.commonconfig import CopySource
from miniopy_async.error import S3Error
from miniopy_async.api import Minio

from app.core.config import settings
from app.core.logger import logger
from app.core.utils import check_extension
from app.core.exceptions import AppException
from app.core.constant import (
Expand All @@ -22,9 +26,41 @@
DOCUMENTS_BUCKET_NAME = CORE_DOCUMENTS_BUCKET_NAME
WA_SIM_BUCKET_NAME = CORE_WA_SIM_BUCKET_NAME

T = TypeVar("T")


async def _with_retries(op_name: str, func: Callable[[], Awaitable[T]]) -> T:
attempts = max(1, settings.MINIO_RETRY_ATTEMPTS)
base_delay = settings.MINIO_RETRY_BASE_SECONDS
last_exc: Exception | None = None

for attempt in range(1, attempts + 1):
try:
return await func()
except S3Error as exc:
if exc.code in {"NoSuchKey", "NoSuchBucket"}:
raise
last_exc = exc
except Exception as exc:
last_exc = exc

logger.warning(
"MinIO %s failed (attempt %s/%s): %s",
op_name,
attempt,
attempts,
last_exc,
)
if attempt < attempts:
await asyncio.sleep(base_delay * attempt)

assert last_exc is not None
raise last_exc

async def init_minio_client(
minio_host: str, minio_port: int, minio_root_user: str, minio_root_password: str
) -> None:
"""Initialize MinIO client and ensure buckets exist."""
Bucket.client = Minio(
f"{minio_host}:{minio_port}",
access_key=minio_root_user,
Expand All @@ -33,10 +69,15 @@ async def init_minio_client(
)

for bucket_name in [IMAGES_BUCKET_NAME, DOCUMENTS_BUCKET_NAME, WA_SIM_BUCKET_NAME]:
if not await Bucket.client.bucket_exists(bucket_name):
await Bucket.client.make_bucket(bucket_name)
async def _ensure_bucket() -> None:
if not await Bucket.client.bucket_exists(bucket_name):
await Bucket.client.make_bucket(bucket_name)

await _with_retries("ensure_bucket", _ensure_bucket)


class Bucket:
"""Bucket helper with retry-aware operations."""
bucket_name: str
file_prefix: str
client: Minio
Expand All @@ -60,44 +101,72 @@ async def put(self, file: UploadFile, object_name: str | None = None) -> str:
if file.filename is None:
file.filename = object_name

await self.client.put_object(
bucket_name=self.bucket_name,
object_name=self._object_path(object_name),
data=file.file,
length=-1,
part_size=10 * 1024 * 1024,
content_type=file.content_type,
metadata={
"filename": file.filename,
},
)
return object_name
attempts = max(1, settings.MINIO_RETRY_ATTEMPTS)
base_delay = settings.MINIO_RETRY_BASE_SECONDS
last_exc: Exception | None = None

for attempt in range(1, attempts + 1):
try:
if hasattr(file.file, "seek"):
file.file.seek(0)
await self.client.put_object(
bucket_name=self.bucket_name,
object_name=self._object_path(object_name),
data=file.file,
length=-1,
part_size=settings.MINIO_PART_SIZE_BYTES,
content_type=file.content_type,
metadata={
"filename": file.filename,
},
)
return object_name
except Exception as exc:
last_exc = exc
logger.warning(
"MinIO put failed for %s (attempt %s/%s): %s",
object_name,
attempt,
attempts,
exc,
)
if attempt < attempts:
await asyncio.sleep(base_delay * attempt)

assert last_exc is not None
raise last_exc

async def get(self, object_name: str) -> tuple[bytes, str, str]:
try:
res = await self.client.get_object(
bucket_name=self.bucket_name,
object_name=self._object_path(object_name),
res = await _with_retries(
"get_object",
lambda: self.client.get_object(
bucket_name=self.bucket_name,
object_name=self._object_path(object_name),
),
)
except S3Error as e:
if e.code == "NoSuchKey":
raise AppException.not_found("File not found")
else:
raise e

data = await res.read()
content_type = (
res.content_type if res.content_type else DEFAULT_CONTENT_TYPE
)
filename = res.headers.get("x-amz-meta-filename", f"{object_name}")

res.close()
return (data, filename, content_type)
try:
data = await res.read()
content_type = (
res.content_type if res.content_type else DEFAULT_CONTENT_TYPE
)
filename = res.headers.get("x-amz-meta-filename", f"{object_name}")
return (data, filename, content_type)
finally:
res.close()

async def delete(self, object_name: str) -> None:
await self.client.remove_object(
bucket_name=self.bucket_name,
object_name=self._object_path(object_name),
await _with_retries(
"remove_object",
lambda: self.client.remove_object(
bucket_name=self.bucket_name,
object_name=self._object_path(object_name),
),
)

async def put_bytes(
Expand All @@ -108,24 +177,30 @@ async def put_bytes(
content_type: str,
filename: str | None = None,
) -> str:
await self.client.put_object(
bucket_name=self.bucket_name,
object_name=self._object_path(object_name),
data=io.BytesIO(data),
length=len(data),
part_size=10 * 1024 * 1024,
content_type=content_type,
metadata={"filename": filename or object_name},
await _with_retries(
"put_object_bytes",
lambda: self.client.put_object(
bucket_name=self.bucket_name,
object_name=self._object_path(object_name),
data=io.BytesIO(data),
length=len(data),
part_size=settings.MINIO_PART_SIZE_BYTES,
content_type=content_type,
metadata={"filename": filename or object_name},
),
)
return object_name

async def copy(self, *, source_object_name: str, target_object_name: str) -> str:
await self.client.copy_object(
bucket_name=self.bucket_name,
object_name=self._object_path(target_object_name),
source=CopySource(
self.bucket_name,
self._object_path(source_object_name),
await _with_retries(
"copy_object",
lambda: self.client.copy_object(
bucket_name=self.bucket_name,
object_name=self._object_path(target_object_name),
source=CopySource(
self.bucket_name,
self._object_path(source_object_name),
),
),
)
return target_object_name
Expand Down
2 changes: 1 addition & 1 deletion app/infra/nats.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ async def ensure_stream(*, stream_name: str, subjects: list[str]) -> None:
try:
await js.stream_info(stream_name)
except NotFoundError:
await js.add_stream(
await js.add_stream( # type: ignore
name=stream_name,
config=StreamConfig(
name=stream_name,
Expand Down
Loading