Zum Hauptinhalt springen

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

EigenschaftWert
AnbieterWasabi
Regioneu-central-2
Bucketcadensa-avatars
Object LockDeaktiviert
Öffentlicher Zugriff❌ Nein (nur über Pre-signed URLs)
Signed URL Gültigkeit7 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.

ParameterWert
Abmessungen400 × 400 px
Anpassungcover (zentrierter Zuschnitt)
FormatWebP
Qualität80%
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

DateiZweck
src/services/storage/wasabi-s3.service.tsS3-Client, uploadAvatar(), deleteAvatar(), getAvatarSignedUrl()
src/services/upload/avatarOptimizer.tssharp-basierte Bildverkleinerung + WebP-Konvertierung
src/services/upload/Upload.service.tsmulter-Konfiguration (memoryStorage)
src/controllers/upload.controller.tsPOST /api/upload/avatar Handler
src/utils/avatarResolver.tsresolveAvatarUrl() + 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

ControllerEndpunktAuflösungspunkt
auth.controller.tsPOST /auth/loginNach Login, vor JWT-Antwort
auth.controller.tsGET /auth/mefetchCurrentUser-Handler
user.controller.tsGET /user/profilegetProfile-Handler
team.controller.tsGET /team/:id/membersNach Aufbau der enrichedMembers-Liste

Frontend-Handling

  • Nach erfolgreichem Upload erhält das Frontend sowohl url (den wasabi:-Schlüssel) als auch signedUrl (die temporäre Bild-URL).
  • Der wasabi:-Schlüssel wird im Benutzerprofil gespeichert (für zukünftige API-Aufrufe).
  • Die signedUrl wird für die sofortige lokale Anzeige verwendet, ohne einen extra API-Rundtrip.
  • avatarUtils.ts und avatarUrl.ts geben undefined für jeden mit wasabi: 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.