Aller au contenu principal

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
FournisseurWasabi
Régioneu-central-2
Bucketcadensa-avatars
Object LockDésactivé
Accès public❌ Non (accès via URLs pré-signées uniquement)
Durée de vie URL signée7 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ètreValeur
Dimensions400 × 400 px
Ajustementcover (recadrage centré)
FormatWebP
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

FichierRôle
src/services/storage/wasabi-s3.service.tsClient S3, uploadAvatar(), deleteAvatar(), getAvatarSignedUrl()
src/services/upload/avatarOptimizer.tsRedimensionnement sharp + conversion WebP
src/services/upload/Upload.service.tsConfiguration multer (memoryStorage)
src/controllers/upload.controller.tsHandler POST /api/upload/avatar
src/utils/avatarResolver.tsHelpers 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ôleurEndpointPoint de résolution
auth.controller.tsPOST /auth/loginAprès login, avant réponse JWT
auth.controller.tsGET /auth/meHandler fetchCurrentUser
user.controller.tsGET /user/profileHandler getProfile
team.controller.tsGET /team/:id/membersAprès construction de la liste enrichedMembers

Gestion côté frontend

  • Après un upload réussi, le frontend reçoit url (la clé wasabi:) et signedUrl (l'URL temporaire de l'image).
  • La clé wasabi: est persistée dans le profil utilisateur (pour les futurs appels API).
  • La signedUrl est utilisée pour l'affichage local immédiat sans aller-retour API supplémentaire.
  • avatarUtils.ts et avatarUrl.ts retournent undefined pour toute valeur commençant par wasabi:, 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.