Architecture de stockage des avatars
Ce document décrit l'implémentation technique du stockage des photos de profil (avatars) dans CADENSA, en utilisant le stockage d'objets Wasabi S3.
Vue d'ensemble
Les photos de profil sont stockées dans le stockage d'objets cloud Wasabi S3, et non dans le système de fichiers local ou dans la base de données. Le backend traite chaque image téléchargée avant de la stocker : il la redimensionne à 400×400 pixels, la convertit au format WebP, puis télécharge le fichier optimisé dans le bucket cadensa-avatars.
L'accès aux images stockées est contrôlé via des URLs pré-signées avec une durée de vie de 7 jours. La clé d'objet Wasabi brute est stockée dans la base de données ; l'URL signée est générée à chaque réponse API.
Infrastructure
| Propriété | Valeur |
|---|---|
| Fournisseur | Wasabi |
| Région | eu-central-2 |
| Bucket | cadensa-avatars |
| Object Lock | Désactivé |
| Accès public | ❌ Non (accès via URLs pré-signées uniquement) |
| Durée de vie URL signée | 7 jours |
Variables d'environnement
WASABI_ACCESS_KEY_ID=...
WASABI_SECRET_ACCESS_KEY=...
WASABI_REGION=eu-central-2
WASABI_ENDPOINT=https://s3.eu-central-2.wasabisys.com
WASABI_BUCKET=cadensa-files # bucket à usage général
WASABI_BUCKET_AVATARS=cadensa-avatars
Pipeline d'upload
Client (navigateur/app)
│
│ POST /api/upload/avatar (multipart/form-data)
▼
multer (memoryStorage)
│ req.file.buffer ← image en RAM, jamais écrite sur disque
▼
avatarOptimizer.ts
│ sharp : redimensionnement cover 400×400
│ conversion WebP, qualité 80
│ retourne : { buffer, contentType: 'image/webp', ext: '.webp', originalSize, optimizedSize }
▼
wasabi-s3.service.ts → uploadAvatar()
│ clé : avatars/{userId}/{timestamp}.webp
│ bucket : cadensa-avatars
▼
Base de données
│ user.avatar = "wasabi:avatars/{userId}/{timestamp}.webp"
▼
Réponse API
│ url: "wasabi:avatars/..." (clé stockée, pour la DB)
│ signedUrl: "https://..." (pré-signée, 7 jours)
Format de la clé d'objet
Les clés d'objets suivent ce modèle :
avatars/{userId}/{unixTimestampMs}.webp
Exemple :
avatars/6627a3f1e2b4c10012345678/1748000000000.webp
La valeur stockée dans user.avatar dans MongoDB est préfixée par wasabi: :
wasabi:avatars/6627a3f1e2b4c10012345678/1748000000000.webp
Ce préfixe permet au backend de distinguer les clés Wasabi des données héritées (ex. URLs Gravatar, noms de fichiers locaux).
Optimisation des images
Implémentée dans cadensa-backend/src/services/upload/avatarOptimizer.ts avec la bibliothèque sharp.
| Paramètre | Valeur |
|---|---|
| Dimensions | 400 × 400 px |
| Ajustement | cover (recadrage centré) |
| Format | WebP |
| Qualité | 80% |
| Réduction de taille typique | ~95–97% par rapport au JPEG/PNG original |
// avatarOptimizer.ts (simplifié)
export async function optimizeAvatar(buffer: Buffer, originalSize: number) {
const optimized = await sharp(buffer)
.resize(400, 400, { fit: 'cover', position: 'centre' })
.webp({ quality: 80 })
.toBuffer();
return {
buffer: optimized,
contentType: 'image/webp',
ext: '.webp',
originalSize,
optimizedSize: optimized.length,
};
}
Flux des URLs pré-signées
Comme le bucket cadensa-avatars n'est pas accessible publiquement, chaque lecture nécessite une URL pré-signée.
1. Le client appelle un endpoint qui retourne des données utilisateur
(GET /auth/me, GET /user/profile, GET /team/:id/members, …)
2. Le backend résout la clé d'avatar brute :
resolveAvatarUrl("wasabi:avatars/...") → getSignedUrl(...)
3. Le SDK AWS génère une URL signée temporaire (expiration 7 jours)
4. L'URL signée est retournée au client dans la réponse JSON
5. Le client affiche l'image directement depuis l'URL signée
Fichiers sources clés
| Fichier | Rôle |
|---|---|
src/services/storage/wasabi-s3.service.ts | Client S3, uploadAvatar(), deleteAvatar(), getAvatarSignedUrl() |
src/services/upload/avatarOptimizer.ts | Redimensionnement sharp + conversion WebP |
src/services/upload/Upload.service.ts | Configuration multer (memoryStorage) |
src/controllers/upload.controller.ts | Handler POST /api/upload/avatar |
src/utils/avatarResolver.ts | Helpers resolveAvatarUrl() + resolveAvatarsInList() |
Utilisation de avatarResolver.ts
resolveAvatarUrl() convertit une valeur brute de la DB en URL signée (ou retourne "" pour les valeurs vides/non-Wasabi) :
import { resolveAvatarUrl, resolveAvatarsInList } from '../utils/avatarResolver';
// Utilisateur unique
const signedUrl = await resolveAvatarUrl(user.avatar);
// Liste de membres d'équipe
await resolveAvatarsInList(
members,
(m) => m.avatar,
(m, url) => { m.avatar = url; }
);
Appelez toujours ces helpers avant d'envoyer la réponse API pour que les clients ne reçoivent jamais une clé wasabi: brute.
Contrôleurs qui résolvent les avatars
| Contrôleur | Endpoint | Point de résolution |
|---|---|---|
auth.controller.ts | POST /auth/login | Après login, avant réponse JWT |
auth.controller.ts | GET /auth/me | Handler fetchCurrentUser |
user.controller.ts | GET /user/profile | Handler getProfile |
team.controller.ts | GET /team/:id/members | Après construction de la liste enrichedMembers |
Gestion côté frontend
- Après un upload réussi, le frontend reçoit
url(la cléwasabi:) etsignedUrl(l'URL temporaire de l'image). - La clé
wasabi:est persistée dans le profil utilisateur (pour les futurs appels API). - La
signedUrlest utilisée pour l'affichage local immédiat sans aller-retour API supplémentaire. avatarUtils.tsetavatarUrl.tsretournentundefinedpour toute valeur commençant parwasabi:, afin que le composant revienne aux initiales — l'URL signée résolue doit toujours provenir de l'API, pas du store Redux.
Suppression d'un avatar
Lorsqu'un utilisateur supprime sa photo de profil, deleteAvatar(key) est appelé sur le service Wasabi avant d'effacer le champ user.avatar dans la base de données :
await wasabiService.deleteAvatar('avatars/{userId}/{timestamp}.webp');
La clé brute (sans le préfixe wasabi:) est passée à la méthode de suppression.