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
| Property | Value |
|---|---|
| Provider | Wasabi |
| Region | eu-central-2 |
| Bucket | cadensa-avatars |
| Object Lock | Disabled |
| Public access | ❌ No (access via pre-signed URLs only) |
| Signed URL TTL | 7 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.
| Parameter | Value |
|---|---|
| Dimensions | 400 × 400 px |
| Fit | cover (centre crop) |
| Format | WebP |
| Quality | 80% |
| 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
| File | Purpose |
|---|---|
src/services/storage/wasabi-s3.service.ts | S3 client, uploadAvatar(), deleteAvatar(), getAvatarSignedUrl() |
src/services/upload/avatarOptimizer.ts | sharp-based image resize + WebP conversion |
src/services/upload/Upload.service.ts | multer config (memoryStorage) |
src/controllers/upload.controller.ts | POST /api/upload/avatar handler |
src/utils/avatarResolver.ts | resolveAvatarUrl() + 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
| Controller | Endpoint | Resolution point |
|---|---|---|
auth.controller.ts | POST /auth/login | After login, before JWT response |
auth.controller.ts | GET /auth/me | fetchCurrentUser handler |
user.controller.ts | GET /user/profile | getProfile handler |
team.controller.ts | GET /team/:id/members | After building enrichedMembers list |
Frontend Handling
- After a successful upload, the frontend receives both
url(thewasabi:key) andsignedUrl(the temporary image URL). - The
wasabi:key is persisted in the user profile (for future API calls to re-generate the signed URL). - The
signedUrlis used for immediate local display without an extra API round-trip. avatarUtils.tsandavatarUrl.tsreturnundefinedfor any value starting withwasabi: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.