Saltar al contenido principal

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

PropiedadValor
ProveedorWasabi
Regióneu-central-2
Bucketcadensa-avatars
Object LockDesactivado
Acceso público❌ No (acceso solo mediante URLs pre-firmadas)
Vigencia de URL firmada7 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ámetroValor
Dimensiones400 × 400 px
Ajustecover (recorte centrado)
FormatoWebP
Calidad80%
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

ArchivoPropósito
src/services/storage/wasabi-s3.service.tsCliente S3, uploadAvatar(), deleteAvatar(), getAvatarSignedUrl()
src/services/upload/avatarOptimizer.tsRedimensionado con sharp + conversión WebP
src/services/upload/Upload.service.tsConfiguración de multer (memoryStorage)
src/controllers/upload.controller.tsHandler POST /api/upload/avatar
src/utils/avatarResolver.tsHelpers 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

ControladorEndpointPunto de resolución
auth.controller.tsPOST /auth/loginTras login, antes de respuesta JWT
auth.controller.tsGET /auth/meHandler fetchCurrentUser
user.controller.tsGET /user/profileHandler getProfile
team.controller.tsGET /team/:id/membersTras construir la lista enrichedMembers

Gestión en el frontend

  • Tras una subida exitosa, el frontend recibe tanto url (la clave wasabi:) como signedUrl (la URL temporal de la imagen).
  • La clave wasabi: se persiste en el perfil de usuario (para futuros llamadas API).
  • La signedUrl se usa para la visualización local inmediata sin un viaje de ida y vuelta API adicional.
  • avatarUtils.ts y avatarUrl.ts devuelven undefined para cualquier valor que comience con wasabi:, 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.