Add comprehensive test suite for image processing and related services
CI / Lint & Format (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled

- Implement tests for database generator to ensure proper session handling.
- Create tests for EXIF extraction and conversion functions.
- Add tests for image-related endpoints, ensuring proper data retrieval and isolation between clients.
- Develop tests for OCR functionality, including language detection and text extraction.
- Introduce tests for the image processing pipeline, covering success and failure scenarios.
- Validate rate limiting functionality and ensure independent counters for different clients.
- Implement scraper tests to verify HTML content fetching and error handling.
- Add unit tests for various services, including storage and filename generation.
- Establish worker entry point for ARQ to handle background image processing tasks.
This commit is contained in:
2026-02-24 11:22:10 -05:00
commit cc99fea20a
80 changed files with 9582 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
from app.routers.images import router as images_router
from app.routers.ai import router as ai_router
from app.routers.auth import router as auth_router
from app.routers.files import router as files_router
__all__ = ["images_router", "ai_router", "auth_router", "files_router"]
+102
View File
@@ -0,0 +1,102 @@
"""
Router — AI : résumé d'URL, rédaction de tâches
Sécurisé : authentification par API Key + scope ai:use + rate limiting.
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from app.dependencies.auth import get_current_client, require_scope
from app.models.client import APIClient
from app.schemas import (
SummarizeRequest, SummarizeResponse,
DraftTaskRequest, DraftTaskResponse,
)
from app.services.scraper import fetch_page_content
from app.services.ai_vision import summarize_url, draft_task
from app.config import settings
from app.middleware import limiter
router = APIRouter(prefix="/ai", tags=["Intelligence Artificielle"])
@router.post(
"/summarize",
response_model=SummarizeResponse,
summary="Résumé AI d'une URL",
description=(
"Scrappe le contenu d'une URL et génère un résumé structuré + tags via AI. "
"Utile pour enrichir les bookmarks Shaarli."
),
dependencies=[Depends(require_scope("ai:use"))],
)
@limiter.limit("1000/hour")
async def summarize_link(
request: Request,
body: SummarizeRequest,
client: APIClient = Depends(get_current_client),
):
if not settings.AI_ENABLED:
raise HTTPException(status_code=503, detail="AI désactivée")
# Scraping
page = await fetch_page_content(body.url)
if page.get("error"):
raise HTTPException(
status_code=422,
detail=f"Impossible de récupérer la page : {page['error']}",
)
content = " ".join(filter(None, [
page.get("title"),
page.get("description"),
page.get("text"),
]))
if not content.strip():
raise HTTPException(status_code=422, detail="Aucun contenu texte trouvé sur cette page")
# Résumé AI
result = await summarize_url(
url=body.url,
content=content,
language=body.language,
)
return SummarizeResponse(
url=body.url,
title=page.get("title"),
summary=result["summary"],
tags=result["tags"],
model=result["model"],
)
@router.post(
"/draft-task",
response_model=DraftTaskResponse,
summary="Rédaction AI d'une tâche",
description=(
"Génère une tâche structurée (titre, description, étapes, priorité) "
"à partir d'une description libre."
),
dependencies=[Depends(require_scope("ai:use"))],
)
@limiter.limit("1000/hour")
async def generate_task(
request: Request,
body: DraftTaskRequest,
client: APIClient = Depends(get_current_client),
):
if not settings.AI_ENABLED:
raise HTTPException(status_code=503, detail="AI désactivée")
result = await draft_task(
description=body.description,
context=body.context,
language=body.language,
)
if not result.get("title"):
raise HTTPException(status_code=500, detail="Échec de la génération de la tâche")
return DraftTaskResponse(**result)
+198
View File
@@ -0,0 +1,198 @@
"""
Router — Auth : gestion des clients API (CRUD + rotation de clé)
"""
import logging
import secrets
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies.auth import get_current_client, hash_api_key, require_scope
from app.models.client import APIClient
from app.schemas.auth import (
ClientCreate,
ClientCreateResponse,
ClientResponse,
ClientUpdate,
KeyRotateResponse,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["Authentification"])
# ─────────────────────────────────────────────────────────────
# CRÉER UN CLIENT
# ─────────────────────────────────────────────────────────────
@router.post(
"/clients",
response_model=ClientCreateResponse,
status_code=status.HTTP_201_CREATED,
summary="Créer un nouveau client API",
description=(
"Crée un client et retourne la clé API **en clair une seule fois**. "
"Stockez-la immédiatement — elle ne sera plus jamais affichée."
),
dependencies=[Depends(require_scope("admin"))],
)
async def create_client(
body: ClientCreate,
db: AsyncSession = Depends(get_db),
) -> ClientCreateResponse:
# Génération de la clé API
raw_key = secrets.token_urlsafe(32)
key_hash = hash_api_key(raw_key)
client = APIClient(
name=body.name,
api_key_hash=key_hash,
scopes=body.scopes,
plan=body.plan,
)
db.add(client)
await db.flush()
await db.refresh(client)
logger.info("Client créé : %s (%s)", client.name, client.id)
return ClientCreateResponse(
id=client.id,
name=client.name,
scopes=client.scopes,
plan=client.plan,
is_active=client.is_active,
created_at=client.created_at,
updated_at=client.updated_at,
api_key=raw_key,
)
# ─────────────────────────────────────────────────────────────
# LISTER LES CLIENTS
# ─────────────────────────────────────────────────────────────
@router.get(
"/clients",
response_model=list[ClientResponse],
summary="Lister tous les clients API",
dependencies=[Depends(require_scope("admin"))],
)
async def list_clients(
db: AsyncSession = Depends(get_db),
) -> list[ClientResponse]:
result = await db.execute(select(APIClient).order_by(APIClient.created_at.desc()))
clients = result.scalars().all()
return [ClientResponse.model_validate(c) for c in clients]
# ─────────────────────────────────────────────────────────────
# DÉTAIL D'UN CLIENT
# ─────────────────────────────────────────────────────────────
@router.get(
"/clients/{client_id}",
response_model=ClientResponse,
summary="Détail d'un client API",
dependencies=[Depends(require_scope("admin"))],
)
async def get_client(
client_id: str,
db: AsyncSession = Depends(get_db),
) -> ClientResponse:
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
client = result.scalar_one_or_none()
if not client:
raise HTTPException(status_code=404, detail="Client introuvable")
return ClientResponse.model_validate(client)
# ─────────────────────────────────────────────────────────────
# MODIFIER UN CLIENT
# ─────────────────────────────────────────────────────────────
@router.patch(
"/clients/{client_id}",
response_model=ClientResponse,
summary="Modifier un client API",
dependencies=[Depends(require_scope("admin"))],
)
async def update_client(
client_id: str,
body: ClientUpdate,
db: AsyncSession = Depends(get_db),
) -> ClientResponse:
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
client = result.scalar_one_or_none()
if not client:
raise HTTPException(status_code=404, detail="Client introuvable")
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(client, field, value)
await db.flush()
await db.refresh(client)
logger.info("Client mis à jour : %s (%s)", client.name, client.id)
return ClientResponse.model_validate(client)
# ─────────────────────────────────────────────────────────────
# ROTATION DE CLÉ
# ─────────────────────────────────────────────────────────────
@router.post(
"/clients/{client_id}/rotate-key",
response_model=KeyRotateResponse,
summary="Régénérer la clé API d'un client",
description="Invalide l'ancienne clé et en génère une nouvelle.",
dependencies=[Depends(require_scope("admin"))],
)
async def rotate_key(
client_id: str,
db: AsyncSession = Depends(get_db),
) -> KeyRotateResponse:
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
client = result.scalar_one_or_none()
if not client:
raise HTTPException(status_code=404, detail="Client introuvable")
raw_key = secrets.token_urlsafe(32)
client.api_key_hash = hash_api_key(raw_key)
await db.flush()
logger.info("Clé API rotée pour client : %s (%s)", client.name, client.id)
return KeyRotateResponse(id=client.id, api_key=raw_key)
# ─────────────────────────────────────────────────────────────
# DÉSACTIVER UN CLIENT (soft delete)
# ─────────────────────────────────────────────────────────────
@router.delete(
"/clients/{client_id}",
response_model=ClientResponse,
summary="Désactiver un client API",
description="Soft delete — marque le client comme inactif sans supprimer les données.",
dependencies=[Depends(require_scope("admin"))],
)
async def delete_client(
client_id: str,
db: AsyncSession = Depends(get_db),
) -> ClientResponse:
result = await db.execute(select(APIClient).where(APIClient.id == client_id))
client = result.scalar_one_or_none()
if not client:
raise HTTPException(status_code=404, detail="Client introuvable")
client.is_active = False
await db.flush()
await db.refresh(client)
logger.info("Client désactivé : %s (%s)", client.name, client.id)
return ClientResponse.model_validate(client)
+68
View File
@@ -0,0 +1,68 @@
"""
Router — Files : sert les fichiers locaux via URLs signées HMAC.
Monté uniquement quand STORAGE_BACKEND == "local".
"""
import logging
from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from fastapi.responses import FileResponse
from app.config import settings
from app.services.storage_backend import get_storage_backend, LocalStorage
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/files", tags=["Fichiers"])
@router.get(
"/signed/{token}",
summary="Télécharger un fichier via URL signée",
description="Valide le token HMAC et retourne le fichier correspondant.",
)
async def serve_signed_file(token: str):
"""Sert un fichier local via un token HMAC signé."""
backend = get_storage_backend()
if not isinstance(backend, LocalStorage):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Endpoint non disponible avec le backend de stockage actuel",
)
# Valider le token
path = backend.validate_token(token)
if path is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Token invalide ou expiré",
)
# Vérifier que le fichier existe
abs_path = backend.get_absolute_path(path)
if not abs_path.exists():
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail="Le fichier n'existe plus",
)
# Détecter le content type
suffix = abs_path.suffix.lower()
mime_map = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".tiff": "image/tiff",
}
media_type = mime_map.get(suffix, "application/octet-stream")
return FileResponse(
path=str(abs_path),
media_type=media_type,
filename=abs_path.name,
)
+500
View File
@@ -0,0 +1,500 @@
"""
Router — Images : upload, lecture, suppression, retraitement
Sécurisé : authentification par API Key + isolation par client_id.
"""
import logging
import math
from typing import Optional
from fastapi import (
APIRouter, Depends, HTTPException, UploadFile, File,
Request, status, Query,
)
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, or_
from app.database import get_db
from app.dependencies.auth import get_current_client, require_scope
from app.models.client import APIClient
from app.models.image import Image, ProcessingStatus
from app.schemas import (
UploadResponse, ImageDetail, ImageSummary,
StatusResponse, PaginatedImages, DeleteResponse,
TagsResponse, ReprocessResponse,
)
from app.services import storage
from app.middleware import limiter, get_upload_rate_limit
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/images", tags=["Images"])
# ─────────────────────────────────────────────────────────────
# UTILITAIRE : récupérer une image avec isolation client
# ─────────────────────────────────────────────────────────────
async def get_image_or_404(
image_id: int, client_id: str, db: AsyncSession
) -> Image:
"""
Récupère une image par ID en vérifiant qu'elle appartient au client.
Lève HTTP 404 si introuvable ou si elle n'appartient pas au client.
"""
result = await db.execute(
select(Image).where(
Image.id == image_id,
Image.client_id == client_id,
)
)
image = result.scalar_one_or_none()
if not image:
raise HTTPException(status_code=404, detail="Image introuvable")
return image
def _dynamic_upload_limit(key: str) -> str:
"""Retourne la limite dynamique basée sur le plan du client."""
# On parse le plan depuis la clé ou le state — fallback free
return get_upload_rate_limit("free")
# ─────────────────────────────────────────────────────────────
# UPLOAD
# ─────────────────────────────────────────────────────────────
@router.post(
"/upload",
response_model=UploadResponse,
status_code=status.HTTP_201_CREATED,
summary="Uploader une image",
description="Upload une image, lance automatiquement le pipeline AI (EXIF + OCR + Vision).",
dependencies=[Depends(require_scope("images:write"))],
)
@limiter.limit("500/hour")
async def upload_image(
request: Request,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
# Vérification quota avant upload
quota_mb = client.quota_storage_mb or 500
used_bytes = client.storage_used_bytes or 0
if used_bytes >= quota_mb * 1024 * 1024:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"Quota de stockage dépassé ({quota_mb} MB)",
)
# Sauvegarde fichier + thumbnail (isolé par client_id)
file_data = await storage.save_upload(file, client_id=client.id)
# Création de l'enregistrement BDD
image = Image(**file_data)
db.add(image)
# Mise à jour du quota
file_size = file_data.get("file_size", 0)
client.storage_used_bytes = (client.storage_used_bytes or 0) + file_size
await db.commit()
await db.refresh(image)
# Enqueue dans ARQ (persistant, avec retry)
arq_pool = request.app.state.arq_pool
queue_name = "premium" if client.plan and client.plan.value == "premium" else "standard"
await arq_pool.enqueue_job(
"process_image_task",
image.id,
str(client.id),
_queue_name=queue_name,
)
return UploadResponse(
id=image.id,
uuid=image.uuid,
original_name=image.original_name,
status=image.processing_status,
)
# ─────────────────────────────────────────────────────────────
# LISTE
# ─────────────────────────────────────────────────────────────
@router.get(
"",
response_model=PaginatedImages,
summary="Lister les images",
dependencies=[Depends(require_scope("images:read"))],
)
async def list_images(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
tag: Optional[str] = Query(None, description="Filtrer par tag AI"),
status_filter: Optional[ProcessingStatus] = Query(None, alias="status"),
search: Optional[str] = Query(None, description="Recherche dans description et OCR"),
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
# Filtre d'isolation par client
query = select(Image).where(Image.client_id == client.id)
if status_filter:
query = query.where(Image.processing_status == status_filter)
if tag:
query = query.where(Image.ai_tags.contains([tag]))
if search:
query = query.where(
or_(
Image.ai_description.ilike(f"%{search}%"),
Image.ocr_text.ilike(f"%{search}%"),
Image.original_name.ilike(f"%{search}%"),
)
)
# Count total
count_query = select(func.count()).select_from(query.subquery())
total_result = await db.execute(count_query)
total = total_result.scalar_one()
# Pagination
offset = (page - 1) * page_size
query = query.order_by(Image.uploaded_at.desc()).offset(offset).limit(page_size)
result = await db.execute(query)
images = result.scalars().all()
# Quota info
used_mb = round((client.storage_used_bytes or 0) / (1024 * 1024), 2)
quota_mb = client.quota_storage_mb or 500
pct = round(used_mb / quota_mb * 100, 1) if quota_mb > 0 else 0.0
return PaginatedImages(
total=total,
page=page,
page_size=page_size,
pages=math.ceil(total / page_size) if total else 0,
storage_used_mb=used_mb,
storage_quota_mb=quota_mb,
quota_pct=pct,
items=[
ImageSummary(
id=img.id,
uuid=img.uuid,
original_name=img.original_name,
mime_type=img.mime_type,
file_size=img.file_size,
width=img.width,
height=img.height,
uploaded_at=img.uploaded_at,
processing_status=img.processing_status,
ai_tags=img.ai_tags,
ai_description=img.ai_description,
thumbnail_path=img.thumbnail_path,
)
for img in images
],
)
# ─────────────────────────────────────────────────────────────
# DÉTAIL COMPLET
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}",
response_model=ImageDetail,
summary="Détail complet d'une image",
description="Retourne toutes les données : fichier, EXIF, OCR et résultats AI.",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_image(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
return ImageDetail.from_orm_full(image)
# ─────────────────────────────────────────────────────────────
# STATUT DU PIPELINE
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}/status",
response_model=StatusResponse,
summary="Statut du traitement AI",
description="Permet de poller l'avancement du pipeline (pending → processing → done/error).",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_status(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
return StatusResponse(
id=image.id,
uuid=image.uuid,
status=image.processing_status,
error=image.processing_error,
started_at=image.processing_started_at,
done_at=image.processing_done_at,
)
# ─────────────────────────────────────────────────────────────
# DONNÉES EXIF
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}/exif",
summary="Métadonnées EXIF de l'image",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_exif(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
return {
"id": image.id,
"camera": {
"make": image.exif_make,
"model": image.exif_model,
"lens": image.exif_lens,
"iso": image.exif_iso,
"aperture": image.exif_aperture,
"shutter_speed": image.exif_shutter,
"focal_length": image.exif_focal,
"flash": image.exif_flash,
"orientation": image.exif_orientation,
"software": image.exif_software,
"taken_at": image.exif_taken_at,
},
"gps": {
"latitude": image.exif_gps_lat,
"longitude": image.exif_gps_lon,
"altitude": image.exif_altitude,
"has_gps": image.has_gps,
"maps_url": (
f"https://maps.google.com/?q={image.exif_gps_lat},{image.exif_gps_lon}"
if image.has_gps else None
),
},
"raw": image.exif_raw,
}
# ─────────────────────────────────────────────────────────────
# DONNÉES OCR
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}/ocr",
summary="Texte extrait de l'image (OCR)",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_ocr(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
return {
"id": image.id,
"has_text": image.ocr_has_text,
"text": image.ocr_text,
"language": image.ocr_language,
"confidence": image.ocr_confidence,
}
# ─────────────────────────────────────────────────────────────
# DONNÉES AI
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}/ai",
summary="Résultats AI (description + tags)",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_ai(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
return {
"id": image.id,
"description": image.ai_description,
"tags": image.ai_tags,
"confidence": image.ai_confidence,
"model_used": image.ai_model_used,
"processed_at": image.ai_processed_at,
"tokens": {
"prompt": image.ai_prompt_tokens,
"output": image.ai_output_tokens,
"total": (image.ai_prompt_tokens or 0) + (image.ai_output_tokens or 0),
},
}
# ─────────────────────────────────────────────────────────────
# TAGS — Vue globale (filtrée par client)
# ─────────────────────────────────────────────────────────────
@router.get(
"/tags/all",
response_model=TagsResponse,
summary="Tous les tags utilisés",
description="Liste dédupliquée de tous les tags AI générés sur les images du client.",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_all_tags(
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
result = await db.execute(
select(Image.ai_tags).where(
Image.client_id == client.id,
Image.ai_tags.isnot(None),
)
)
all_tag_lists = result.scalars().all()
unique_tags = sorted(set(
tag
for tag_list in all_tag_lists
for tag in (tag_list or [])
))
return TagsResponse(tags=unique_tags, total=len(unique_tags))
# ─────────────────────────────────────────────────────────────
# RETRAITEMENT AI
# ─────────────────────────────────────────────────────────────
@router.post(
"/{image_id}/reprocess",
response_model=ReprocessResponse,
summary="Relancer le pipeline AI",
description="Reprocess une image existante (utile après changement de modèle AI).",
dependencies=[Depends(require_scope("images:write"))],
)
@limiter.limit("500/hour")
async def reprocess_image(
request: Request,
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
# Reset du statut
image.processing_status = ProcessingStatus.PENDING
image.processing_error = None
image.processing_started_at = None
image.processing_done_at = None
await db.commit()
# Enqueue dans ARQ
arq_pool = request.app.state.arq_pool
queue_name = "premium" if client.plan and client.plan.value == "premium" else "standard"
await arq_pool.enqueue_job(
"process_image_task",
image_id,
str(client.id),
_queue_name=queue_name,
)
return ReprocessResponse(id=image_id)
# ─────────────────────────────────────────────────────────────
# SUPPRESSION
# ─────────────────────────────────────────────────────────────
@router.delete(
"/{image_id}",
response_model=DeleteResponse,
summary="Supprimer une image",
dependencies=[Depends(require_scope("images:delete"))],
)
async def delete_image(
image_id: int,
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
image = await get_image_or_404(image_id, client.id, db)
# Décrémentation du quota
file_size = image.file_size or 0
client.storage_used_bytes = max(0, (client.storage_used_bytes or 0) - file_size)
# Suppression des fichiers sur disque
storage.delete_files(image.file_path, image.thumbnail_path)
await db.delete(image)
await db.commit()
return DeleteResponse(deleted_id=image_id)
# ─────────────────────────────────────────────────────────────
# URLs SIGNÉES
# ─────────────────────────────────────────────────────────────
@router.get(
"/{image_id}/download-url",
summary="URL signée de téléchargement",
description="Retourne une URL signée temporaire pour télécharger l'image originale.",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_download_url(
image_id: int,
expires_in: int = Query(900, ge=60, le=86400, description="Durée de validité en secondes"),
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
from app.services.storage_backend import get_storage_backend
image = await get_image_or_404(image_id, client.id, db)
backend = get_storage_backend()
url = await backend.get_signed_url(image.file_path, expires_in=expires_in)
return {"url": url, "expires_in": expires_in}
@router.get(
"/{image_id}/thumbnail-url",
summary="URL signée du thumbnail",
description="Retourne une URL signée temporaire pour le thumbnail de l'image.",
dependencies=[Depends(require_scope("images:read"))],
)
async def get_thumbnail_url(
image_id: int,
expires_in: int = Query(900, ge=60, le=86400, description="Durée de validité en secondes"),
db: AsyncSession = Depends(get_db),
client: APIClient = Depends(get_current_client),
):
from app.services.storage_backend import get_storage_backend
image = await get_image_or_404(image_id, client.id, db)
if not image.thumbnail_path:
raise HTTPException(status_code=404, detail="Thumbnail non disponible")
backend = get_storage_backend()
url = await backend.get_signed_url(image.thumbnail_path, expires_in=expires_in)
return {"url": url, "expires_in": expires_in}