Arquitectura de almacenamiento de avatares
Este documento describe la implementación técnica del almacenamiento de fotos de perfil (avatares) en CADENSA usando el almacenamiento de objetos Wasabi S3.
Descripción general
Las fotos de perfil se almacenan en el almacenamiento de objetos en la nube Wasabi S3, no en el sistema de archivos local ni en la base de datos. El backend procesa cada imagen subida antes de almacenarla: la redimensiona a 400×400 píxeles, la convierte a formato WebP y sube el archivo optimizado al bucket cadensa-avatars.
El acceso a las imágenes almacenadas se controla mediante URLs pre-firmadas con un TTL de 7 días. La clave de objeto Wasabi sin procesar se almacena en la base de datos; la URL firmada se genera en cada respuesta API.
Infraestructura
| Propiedad | Valor |
|---|---|
| Proveedor | Wasabi |
| Región | eu-central-2 |
| Bucket | cadensa-avatars |
| Object Lock | Desactivado |
| Acceso público | ❌ No (acceso solo mediante URLs pre-firmadas) |
| Vigencia de URL firmada | 7 días |
Variables de entorno
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 de propósito general
WASABI_BUCKET_AVATARS=cadensa-avatars
Pipeline de subida
Cliente (navegador/app)
│
│ POST /api/upload/avatar (multipart/form-data)
▼
multer (memoryStorage)
│ req.file.buffer ← imagen en RAM, nunca escrita en disco
▼
avatarOptimizer.ts
│ sharp: redimensionado cover 400×400
│ conversión WebP, calidad 80
│ devuelve: { buffer, contentType: 'image/webp', ext: '.webp', originalSize, optimizedSize }
▼
wasabi-s3.service.ts → uploadAvatar()
│ clave: avatars/{userId}/{timestamp}.webp
│ bucket: cadensa-avatars
▼
Base de datos
│ user.avatar = "wasabi:avatars/{userId}/{timestamp}.webp"
▼
Respuesta API
│ url: "wasabi:avatars/..." (clave almacenada, para DB)
│ signedUrl: "https://..." (pre-firmada, 7 días)
Formato de clave de objeto
Las claves de objeto siguen este patrón:
avatars/{userId}/{unixTimestampMs}.webp
Ejemplo:
avatars/6627a3f1e2b4c10012345678/1748000000000.webp
El valor almacenado en user.avatar en MongoDB tiene el prefijo wasabi::
wasabi:avatars/6627a3f1e2b4c10012345678/1748000000000.webp
Este prefijo permite al backend distinguir las claves Wasabi de los datos heredados (p. ej. URLs de Gravatar, nombres de archivos locales).
Optimización de imágenes
Implementada en cadensa-backend/src/services/upload/avatarOptimizer.ts con la biblioteca sharp.
| Parámetro | Valor |
|---|---|
| Dimensiones | 400 × 400 px |
| Ajuste | cover (recorte centrado) |
| Formato | WebP |
| Calidad | 80% |
| Reducción de tamaño típica | ~95–97% respecto al JPEG/PNG original |
// avatarOptimizer.ts (simplificado)
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,
};
}
Flujo de URLs pre-firmadas
Como el bucket cadensa-avatars no es de acceso público, cada lectura requiere una URL pre-firmada.
1. El cliente llama a un endpoint que devuelve datos de usuario
(GET /auth/me, GET /user/profile, GET /team/:id/members, …)
2. El backend resuelve la clave de avatar sin procesar:
resolveAvatarUrl("wasabi:avatars/...") → getSignedUrl(...)
3. El SDK de AWS genera una URL firmada temporal (expiración 7 días)
4. La URL firmada se devuelve al cliente en la respuesta JSON
5. El cliente renderiza la imagen directamente desde la URL firmada
Archivos fuente clave
| Archivo | Propósito |
|---|---|
src/services/storage/wasabi-s3.service.ts | Cliente S3, uploadAvatar(), deleteAvatar(), getAvatarSignedUrl() |
src/services/upload/avatarOptimizer.ts | Redimensionado con sharp + conversión WebP |
src/services/upload/Upload.service.ts | Configuración de multer (memoryStorage) |
src/controllers/upload.controller.ts | Handler POST /api/upload/avatar |
src/utils/avatarResolver.ts | Helpers resolveAvatarUrl() + resolveAvatarsInList() |
Uso de avatarResolver.ts
resolveAvatarUrl() convierte un valor bruto de la DB en una URL firmada (o devuelve "" para valores vacíos/no-Wasabi):
import { resolveAvatarUrl, resolveAvatarsInList } from '../utils/avatarResolver';
// Usuario único
const signedUrl = await resolveAvatarUrl(user.avatar);
// Lista de miembros del equipo
await resolveAvatarsInList(
members,
(m) => m.avatar,
(m, url) => { m.avatar = url; }
);
Llama siempre a estos helpers antes de enviar la respuesta API para que los clientes nunca reciban una clave wasabi: sin procesar.
Controladores que resuelven avatares
| Controlador | Endpoint | Punto de resolución |
|---|---|---|
auth.controller.ts | POST /auth/login | Tras login, antes de respuesta JWT |
auth.controller.ts | GET /auth/me | Handler fetchCurrentUser |
user.controller.ts | GET /user/profile | Handler getProfile |
team.controller.ts | GET /team/:id/members | Tras construir la lista enrichedMembers |
Gestión en el frontend
- Tras una subida exitosa, el frontend recibe tanto
url(la clavewasabi:) comosignedUrl(la URL temporal de la imagen). - La clave
wasabi:se persiste en el perfil de usuario (para futuros llamadas API). - La
signedUrlse usa para la visualización local inmediata sin un viaje de ida y vuelta API adicional. avatarUtils.tsyavatarUrl.tsdevuelvenundefinedpara cualquier valor que comience conwasabi:, de modo que el componente recurra a las iniciales — la URL firmada resuelta siempre debe provenir de la API, no del store Redux.
Eliminar un avatar
Cuando un usuario elimina su foto de perfil, se llama a deleteAvatar(key) en el servicio Wasabi antes de limpiar el campo user.avatar en la base de datos:
await wasabiService.deleteAvatar('avatars/{userId}/{timestamp}.webp');
La clave sin procesar (sin el prefijo wasabi:) se pasa al método de eliminación.