""" Service de stockage — sauvegarde fichiers, génération thumbnails Multi-tenant : les fichiers sont isolés par client_id. """ import uuid import logging import io from pathlib import Path from datetime import datetime, timezone from PIL import Image as PILImage from fastapi import UploadFile, HTTPException, status from app.config import settings from app.services.storage_backend import get_storage_backend logger = logging.getLogger(__name__) ALLOWED_MIME_TYPES = { "image/jpeg", "image/png", "image/gif", "image/webp", "image/bmp", "image/tiff", } THUMBNAIL_SIZE = (320, 320) def _generate_filename(original: str) -> tuple[str, str]: """Retourne (uuid_filename, extension).""" suffix = Path(original).suffix.lower() or ".jpg" uid = str(uuid.uuid4()) return f"{uid}{suffix}", uid async def save_upload(file: UploadFile, client_id: str) -> dict: """ Valide, sauvegarde le fichier uploadé et génère un thumbnail. Utilise le backend de stockage configuré (Local ou S3). """ # ── Validation MIME ─────────────────────────────────────── if file.content_type not in ALLOWED_MIME_TYPES: raise HTTPException( status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail=f"Type non supporté : {file.content_type}. " f"Acceptés : {', '.join(ALLOWED_MIME_TYPES)}", ) # ── Lecture du contenu ──────────────────────────────────── content = await file.read() if len(content) > settings.max_upload_bytes: raise HTTPException( status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail=f"Fichier trop volumineux. Max : {settings.MAX_UPLOAD_SIZE_MB} MB", ) # ── Nommage ─────────────────────────────────────────────── filename, file_uuid = _generate_filename(file.filename or "image") # Chemins relatifs par rapport au bucket/base_dir rel_file_path = f"uploads/{client_id}/{filename}" rel_thumb_path = f"thumbnails/{client_id}/thumb_{filename}" backend = get_storage_backend() # ── Sauvegarde fichier original ─────────────────────────── await backend.save(content, rel_file_path, file.content_type) # ── Dimensions + thumbnail ──────────────────────────────── width, height = None, None thumb_saved = False try: # On utilise io.BytesIO pour ne pas avoir à écrire sur le disque local with PILImage.open(io.BytesIO(content)) as img: width, height = img.size img.thumbnail(THUMBNAIL_SIZE, PILImage.LANCZOS) # Convertit en RGB si nécessaire if img.mode in ("RGBA", "P"): img = img.convert("RGB") # Sauvegarde thumbnail dans un buffer thumb_buffer = io.BytesIO() img.save(thumb_buffer, "JPEG", quality=85) thumb_data = thumb_buffer.getvalue() # Sauvegarde via le backend await backend.save(thumb_data, rel_thumb_path, "image/jpeg") thumb_saved = True except Exception as e: logger.warning("Erreur génération thumbnail : %s", e) return { "uuid": file_uuid, "original_name": file.filename, "filename": filename, "file_path": rel_file_path, "thumbnail_path": rel_thumb_path if thumb_saved else None, "mime_type": file.content_type, "file_size": len(content), "width": width, "height": height, "uploaded_at": datetime.now(timezone.utc), "client_id": client_id, } def delete_files(file_path: str, thumbnail_path: str | None = None) -> None: """Supprime le fichier original et son thumbnail via le backend.""" import asyncio backend = get_storage_backend() async def _do_delete(): await backend.delete(file_path) if thumbnail_path: await backend.delete(thumbnail_path) # Note: delete_files est synchrone dans les routers existants, # mais le backend est async. C'est un risque. # TODO: Refactorer delete_image pour être full async. try: loop = asyncio.get_event_loop() if loop.is_running(): asyncio.ensure_future(_do_delete()) else: loop.run_until_complete(_do_delete()) except Exception: pass