Imago/app/services/storage.py

127 lines
4.7 KiB
Python

"""
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