Skip to main content

Avatar Storage Architecture

This document describes the technical implementation of profile picture (avatar) storage in CADENSA using Wasabi S3 object storage.


Overview

Profile pictures are stored in Wasabi S3 cloud object storage rather than the local filesystem or the database. The backend processes every uploaded image before storing it: it resizes it to 400×400 pixels, converts it to WebP format, and uploads the optimised file to the cadensa-avatars bucket.

Access to stored images is controlled via pre-signed URLs with a 7-day TTL. The raw Wasabi object key is stored in the database; the signed URL is generated on every API response.


Infrastructure

PropertyValue
ProviderWasabi
Regioneu-central-2
Bucketcadensa-avatars
Object LockDisabled
Public access❌ No (access via pre-signed URLs only)
Signed URL TTL7 days

Environment variables

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 # general-purpose bucket
WASABI_BUCKET_AVATARS=cadensa-avatars

Upload Pipeline

Client (browser/app)

│ POST /api/upload/avatar (multipart/form-data)

multer (memoryStorage)
│ req.file.buffer ← image in RAM, never written to disk

avatarOptimizer.ts
│ sharp: resize 400×400 cover crop
│ convert to WebP, quality 80
│ returns { buffer, contentType: 'image/webp', ext: '.webp', originalSize, optimizedSize }

wasabi-s3.service.ts → uploadAvatar()
│ key: avatars/{userId}/{timestamp}.webp
│ bucket: cadensa-avatars

Database
│ user.avatar = "wasabi:avatars/{userId}/{timestamp}.webp"

API Response
│ url: "wasabi:avatars/..." (stored key, for DB)
│ signedUrl: "https://..." (pre-signed, 7 days)

Object Key Format

Object keys follow this pattern:

avatars/{userId}/{unixTimestampMs}.webp

Example:

avatars/6627a3f1e2b4c10012345678/1748000000000.webp

The value stored in user.avatar in MongoDB is prefixed with wasabi::

wasabi:avatars/6627a3f1e2b4c10012345678/1748000000000.webp

This prefix allows the backend to distinguish Wasabi keys from legacy data (e.g. Gravatar URLs, local filenames).


Image Optimisation

Implemented in cadensa-backend/src/services/upload/avatarOptimizer.ts using the sharp library.

ParameterValue
Dimensions400 × 400 px
Fitcover (centre crop)
FormatWebP
Quality80%
Typical size reduction~95–97% vs. original JPEG/PNG
// avatarOptimizer.ts (simplified)
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,
};
}

Pre-signed URL Flow

Because the cadensa-avatars bucket is not publicly accessible, every read requires a pre-signed URL.

1. Client calls any endpoint that returns user data
(GET /auth/me, GET /user/profile, GET /team/:id/members, …)

2. Backend resolves raw avatar key:
resolveAvatarUrl("wasabi:avatars/...") → getSignedUrl(...)

3. AWS SDK generates a temporary signed URL (7-day expiry)

4. Signed URL returned to client in JSON response

5. Client renders the image directly from the signed URL

The signed URL contains authentication information in query parameters and expires automatically after 7 days. On next API call a new signed URL is issued.


Key Source Files

FilePurpose
src/services/storage/wasabi-s3.service.tsS3 client, uploadAvatar(), deleteAvatar(), getAvatarSignedUrl()
src/services/upload/avatarOptimizer.tssharp-based image resize + WebP conversion
src/services/upload/Upload.service.tsmulter config (memoryStorage)
src/controllers/upload.controller.tsPOST /api/upload/avatar handler
src/utils/avatarResolver.tsresolveAvatarUrl() + resolveAvatarsInList() helpers

avatarResolver.ts Usage

resolveAvatarUrl() converts a raw DB value to a signed URL (or returns "" for empty/non-Wasabi values):

import { resolveAvatarUrl, resolveAvatarsInList } from '../utils/avatarResolver';

// Single user
const signedUrl = await resolveAvatarUrl(user.avatar);

// List of team members
await resolveAvatarsInList(
members,
(m) => m.avatar,
(m, url) => { m.avatar = url; }
);

Always call these helpers before sending the API response so clients never receive a raw wasabi: key.


Controllers That Resolve Avatars

ControllerEndpointResolution point
auth.controller.tsPOST /auth/loginAfter login, before JWT response
auth.controller.tsGET /auth/mefetchCurrentUser handler
user.controller.tsGET /user/profilegetProfile handler
team.controller.tsGET /team/:id/membersAfter building enrichedMembers list

Frontend Handling

  • After a successful upload, the frontend receives both url (the wasabi: key) and signedUrl (the temporary image URL).
  • The wasabi: key is persisted in the user profile (for future API calls to re-generate the signed URL).
  • The signedUrl is used for immediate local display without an extra API round-trip.
  • avatarUtils.ts and avatarUrl.ts return undefined for any value starting with wasabi: so the component falls back to initials rather than rendering a broken URL — the resolved signed URL should always come from the API, not from stored Redux state.

Deleting an Avatar

When a user removes their profile picture, deleteAvatar(key) is called on the Wasabi service before clearing the user.avatar field in the database:

await wasabiService.deleteAvatar('avatars/{userId}/{timestamp}.webp');

The raw key (without the wasabi: prefix) is passed to the delete method.