Avatar-Speicherarchitektur
Dieses Dokument beschreibt die technische Implementierung der Profilbild-Speicherung in CADENSA mithilfe von Wasabi S3 Objektspeicher.
Überblick
Profilbilder werden im Wasabi S3 Cloud-Objektspeicher abgelegt, nicht im lokalen Dateisystem oder in der Datenbank. Das Backend verarbeitet jedes hochgeladene Bild vor der Speicherung: Es wird auf 400×400 Pixel zugeschnitten, in WebP konvertiert und die optimierte Datei in den cadensa-avatars Bucket hochgeladen.
Der Zugriff auf gespeicherte Bilder erfolgt über Pre-signed URLs mit einer Gültigkeitsdauer von 7 Tagen. Der rohe Wasabi-Objektschlüssel wird in der Datenbank gespeichert; die signierte URL wird bei jeder API-Antwort generiert.
Infrastruktur
| Eigenschaft | Wert |
|---|---|
| Anbieter | Wasabi |
| Region | eu-central-2 |
| Bucket | cadensa-avatars |
| Object Lock | Deaktiviert |
| Öffentlicher Zugriff | ❌ Nein (nur über Pre-signed URLs) |
| Signed URL Gültigkeit | 7 Tage |
Umgebungsvariablen
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 # Allzweck-Bucket
WASABI_BUCKET_AVATARS=cadensa-avatars
Upload-Pipeline
Client (Browser/App)
│
│ POST /api/upload/avatar (multipart/form-data)
▼
multer (memoryStorage)
│ req.file.buffer ← Bild im RAM, nie auf Disk geschrieben
▼
avatarOptimizer.ts
│ sharp: 400×400 Cover-Crop
│ WebP-Konvertierung, 80% Qualität
│ Rückgabe: { buffer, contentType: 'image/webp', ext: '.webp', originalSize, optimizedSize }
▼
wasabi-s3.service.ts → uploadAvatar()
│ Schlüssel: avatars/{userId}/{timestamp}.webp
│ Bucket: cadensa-avatars
▼
Datenbank
│ user.avatar = "wasabi:avatars/{userId}/{timestamp}.webp"
▼
API-Antwort
│ url: "wasabi:avatars/..." (gespeicherter Schlüssel, für DB)
│ signedUrl: "https://..." (pre-signed, 7 Tage)
Objektschlüssel-Format
Objektschlüssel folgen diesem Muster:
avatars/{userId}/{unixTimestampMs}.webp
Beispiel:
avatars/6627a3f1e2b4c10012345678/1748000000000.webp
Der in user.avatar in MongoDB gespeicherte Wert ist mit wasabi: präfixiert:
wasabi:avatars/6627a3f1e2b4c10012345678/1748000000000.webp
Dieses Präfix ermöglicht dem Backend, Wasabi-Schlüssel von älteren Daten (z. B. Gravatar-URLs, lokale Dateinamen) zu unterscheiden.
Bildoptimierung
Implementiert in cadensa-backend/src/services/upload/avatarOptimizer.ts mit der sharp-Bibliothek.
| Parameter | Wert |
|---|---|
| Abmessungen | 400 × 400 px |
| Anpassung | cover (zentrierter Zuschnitt) |
| Format | WebP |
| Qualität | 80% |
| Typische Größenreduktion | ~95–97% gegenüber Original-JPEG/PNG |
// avatarOptimizer.ts (vereinfacht)
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 Ablauf
Da der cadensa-avatars Bucket nicht öffentlich zugänglich ist, erfordert jeder Lesezugriff eine Pre-signed URL.
1. Client ruft einen Endpunkt auf, der Benutzerdaten zurückgibt
(GET /auth/me, GET /user/profile, GET /team/:id/members, …)
2. Backend löst den rohen Avatar-Schlüssel auf:
resolveAvatarUrl("wasabi:avatars/...") → getSignedUrl(...)
3. AWS SDK generiert eine temporäre signierte URL (7 Tage Gültigkeit)
4. Signierte URL wird dem Client in der JSON-Antwort zurückgegeben
5. Client rendert das Bild direkt von der signierten URL
Wichtige Quelldateien
| Datei | Zweck |
|---|---|
src/services/storage/wasabi-s3.service.ts | S3-Client, uploadAvatar(), deleteAvatar(), getAvatarSignedUrl() |
src/services/upload/avatarOptimizer.ts | sharp-basierte Bildverkleinerung + WebP-Konvertierung |
src/services/upload/Upload.service.ts | multer-Konfiguration (memoryStorage) |
src/controllers/upload.controller.ts | POST /api/upload/avatar Handler |
src/utils/avatarResolver.ts | resolveAvatarUrl() + resolveAvatarsInList() Hilfsfunktionen |
avatarResolver.ts Verwendung
resolveAvatarUrl() konvertiert einen rohen DB-Wert in eine signierte URL (oder gibt "" für leere/Nicht-Wasabi-Werte zurück):
import { resolveAvatarUrl, resolveAvatarsInList } from '../utils/avatarResolver';
// Einzelner Benutzer
const signedUrl = await resolveAvatarUrl(user.avatar);
// Liste von Teammitgliedern
await resolveAvatarsInList(
members,
(m) => m.avatar,
(m, url) => { m.avatar = url; }
);
Rufe diese Hilfsfunktionen immer vor dem Senden der API-Antwort auf, damit Clients nie einen rohen wasabi:-Schlüssel erhalten.
Controller, die Avatare auflösen
| Controller | Endpunkt | Auflösungspunkt |
|---|---|---|
auth.controller.ts | POST /auth/login | Nach Login, vor JWT-Antwort |
auth.controller.ts | GET /auth/me | fetchCurrentUser-Handler |
user.controller.ts | GET /user/profile | getProfile-Handler |
team.controller.ts | GET /team/:id/members | Nach Aufbau der enrichedMembers-Liste |
Frontend-Handling
- Nach erfolgreichem Upload erhält das Frontend sowohl
url(denwasabi:-Schlüssel) als auchsignedUrl(die temporäre Bild-URL). - Der
wasabi:-Schlüssel wird im Benutzerprofil gespeichert (für zukünftige API-Aufrufe). - Die
signedUrlwird für die sofortige lokale Anzeige verwendet, ohne einen extra API-Rundtrip. avatarUtils.tsundavatarUrl.tsgebenundefinedfür jeden mitwasabi:beginnenden Wert zurück, sodass die Komponente auf Initialen zurückfällt — die aufgelöste signierte URL sollte immer von der API kommen, nicht aus dem Redux-Store.
Avatar löschen
Wenn ein Benutzer sein Profilbild entfernt, wird deleteAvatar(key) am Wasabi-Service aufgerufen, bevor das Feld user.avatar in der Datenbank geleert wird:
await wasabiService.deleteAvatar('avatars/{userId}/{timestamp}.webp');
Der rohe Schlüssel (ohne wasabi:-Präfix) wird an die Delete-Methode übergeben.