Datei-Uploads in NestJS: von Multer zu @nestjs/storage
· 8 Min. Lesezeit
Profilbilder, Rechnungen, PDFs: Fast jede App nimmt irgendwann Dateien entgegen. In NestJS war das bisher immer ein Stück Eigenbau, spätestens sobald die Dateien in einem Cloud-Speicher wie Amazon S3 landen sollten. Mit @nestjs/storage gibt es dafür jetzt ein offizielles Paket.
Dieser Beitrag gehört zu meiner kleinen Serie über die neuen offiziellen NestJS-Pakete. Ebenfalls erschienen: Cronjobs mit @nestjs/locks und E-Mails mit @nestjs/mail.
Kurz gesagt
- Neu:
@nestjs/storagespeichert hochgeladene Dateien wahlweise lokal oder in einem Cloud-Speicher, mit demselben Code. - Betroffen: NestJS-Apps, die Dateien entgegennehmen, bisher meist über das Paket Multer und selbst geschriebenen Code für den Cloud-Speicher.
- Zu tun: Bei neuen Projekten direkt das neue Paket nehmen. Bestehende Projekte laufen weiter und müssen nicht sofort umsteigen, da das Paket noch sehr jung ist.
- Nutzen: Zu große oder falsche Dateien werden abgelehnt, bevor sie gespeichert sind. Das spart eigenen Code und Aufräumarbeiten.
Das Problem: erst speichern, dann prüfen
Multer nimmt in NestJS die Uploads entgegen und reicht sie an eine Storage-Engine weiter. Mitgeliefert sind nur memoryStorage() und diskStorage(). Für S3 habe ich deshalb in Kundenprojekten jedes Mal eigene Services und Engines geschrieben.
Am meisten genervt hat mich die Validierung der Dateigröße. In NestJS laufen Pipes wie ParseFilePipe und der Handler erst nach dem Interceptor, also nachdem die Storage-Engine die Datei gespeichert hat. Streamt die Engine direkt in den Bucket, liegt eine zu große Datei dort also schon, wenn die Prüfung fehlschlägt. Ums Löschen musste ich mich selbst kümmern:
import { FileInterceptor } from "@nestjs/platform-express";
// vereinfachtes Beispiel: S3StorageEngine und S3Service stehen für
// eigenen Code, der die Datei direkt in den S3-Bucket streamt bzw. löscht
import { S3Service } from "../storage/s3.service";
import { S3StorageEngine } from "../storage/s3-storage.engine";
const MAX_PHOTO_SIZE = 5 * 1024 * 1024; // 5 MB
@Controller()
export class BeanController {
constructor(
private readonly beanService: BeanService,
private readonly s3Service: S3Service,
) {}
@Post("beans/:id/photo")
@UseInterceptors(
FileInterceptor("photo", {
storage: new S3StorageEngine("bean-photos"),
}),
)
async uploadPhoto(
@Param("id") id: string,
@UploadedFile() file: Express.Multer.File & { key: string },
) {
// Größenprüfung, hier von Hand; mit ParseFilePipe und
// MaxFileSizeValidator läuft sie genauso spät
if (file.size > MAX_PHOTO_SIZE) {
// die Datei liegt zu diesem Zeitpunkt schon im Bucket
await this.s3Service.delete(file.key);
throw new BadRequestException("Ungültiges Foto");
}
return this.beanService.setPhoto(id, file.key);
}
}
Dazu kommt: Lokal entwickelt man mit diskStorage(), in Production mit S3, also zwei Codepfade.
Die Lösung mit @nestjs/storage
@nestjs/storage ersetzt Multer nicht. FileInterceptor bleibt, das Paket liefert die Storage-Engine und eine einheitliche API für den Speicher. Den S3-Client bringt es selbst mit, ein AWS SDK ist nicht nötig.
1. Paket installieren
npm i --save @nestjs/storage
Getestet habe ich das Beispiel mit NestJS 12 und @nestjs/storage 0.0.5.
2. Disks konfigurieren
Speicherorte heißen hier Disks. Jede Disk hat einen Namen und einen Treiber. Welcher Treiber genutzt wird, entscheidet eine Umgebungsvariable:
import { LocalDisk, S3Disk, StorageModule } from "@nestjs/storage";
const appUrl = process.env.APP_URL ?? "http://localhost:3000";
@Module({
imports: [
StorageModule.forRootAsync({
useFactory: () => ({
default: "photos",
disks: {
photos:
process.env.STORAGE_DRIVER === "s3"
? new S3Disk({
bucket: process.env.S3_PHOTOS_BUCKET!,
region: process.env.AWS_REGION,
publicUrl: process.env.S3_PHOTOS_URL,
})
: new LocalDisk({
root: "var/storage/photos",
publicUrl: `${appUrl}/photos`,
}),
},
}),
}),
],
})
export class StorageConfigModule {}
Das Modul ist global. Für Cloudflare R2, MinIO oder Backblaze B2 nimmst du ebenfalls S3Disk und setzt zusätzlich endpoint.
3. Upload über die neue Engine
Im Controller tauschst du nur die Engine aus. Erlaubte Typen und Maximalgröße wandern in die Optionen des Interceptors:
import { FileInterceptor } from "@nestjs/platform-express";
import { type StoredUpload, uploadToDisk } from "@nestjs/storage"; import { S3Service } from "../storage/s3.service"; import { S3StorageEngine } from "../storage/s3-storage.engine";
const PHOTO_TYPES = ["image/jpeg", "image/png", "image/webp"]; const MAX_PHOTO_SIZE = 5 * 1024 * 1024; // 5 MB
@Controller()
export class BeanController {
constructor(
private readonly beanService: BeanService,
private readonly s3Service: S3Service, ) {}
@Post("beans/:id/photo")
@UseInterceptors(
FileInterceptor("photo", {
storage: new S3StorageEngine("bean-photos"), storage: uploadToDisk({ disk: "photos", contentTypes: PHOTO_TYPES }), limits: { fileSize: MAX_PHOTO_SIZE, files: 1 }, }),
)
async uploadPhoto(
@Param("id") id: string,
@UploadedFile() file: Express.Multer.File & { key: string }, @UploadedFile() file: StoredUpload | undefined, ) {
if (file.size > MAX_PHOTO_SIZE) { await this.s3Service.delete(file.key); throw new BadRequestException("Ungültiges Foto"); } if (!file) throw new BadRequestException("Kein Foto, keine Bohne"); return this.beanService.setPhoto(id, file.key);
}
}
Ist die Datei zu groß, antwortet die App mit 413, bei falschem Typ mit 415. In beiden Fällen wird nichts gespeichert. Der Key ist standardmäßig eine UUID plus erkannte Endung, der Dateiname des Clients spielt keine Rolle.
4. URL zurückgeben
Im Service injizierst du die Disk über ihren Namen:
import { InjectDisk, StorageDisk } from "@nestjs/storage";
@Injectable()
export class BeanService {
constructor(@InjectDisk("photos") private readonly photos: StorageDisk) {}
async setPhoto(id: string, key: string) {
// ... Key an der Bohnensorte speichern
return { id, photoUrl: this.photos.url(key) };
}
}
url() liefert je nach Disk die URL aus dem Bucket oder die lokale. Für die lokale Disk muss die App die Fotos selbst ausliefern:
import { InjectDisk, serveFile, StorageDisk } from "@nestjs/storage";
import type { Request, Response } from "express";
@Controller()
export class PhotoController {
constructor(@InjectDisk("photos") private readonly photos: StorageDisk) {}
@Get("photos/:file")
servePhoto(
@Param("file") file: string,
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
) {
return serveFile(this.photos, file, { req, res, disposition: "inline" });
}
}
serveFile() kümmert sich um Range-Requests, ETags und den Header X-Content-Type-Options: nosniff.
Warum das funktioniert
uploadToDisk() liest die ersten Bytes der Datei, bevor es schreibt, und erkennt den Typ daran (JPEG, PNG, GIF, WebP, AVIF, HEIC und PDF sind eingebaut). Passt der Typ nicht zu contentTypes, wird kein einziges Byte gespeichert.
Die Größe prüft Multer über limits.fileSize schon während des Uploads. Wird das Limit überschritten, verwirft die Engine, was sie bisher empfangen hat. Schreibvorgänge sind atomar, ein abgebrochener Upload hinterlässt also keine halbe Datei. Genau den Aufräumcode, den ich früher selbst geschrieben habe, braucht es dadurch nicht mehr.
Für Tests gibt es zusätzlich eine InMemoryDisk, die du per overrideProvider(STORAGE_MODULE_OPTIONS) einsetzt. Dann brauchst du weder Bucket noch Dateisystem.
Stolperfallen
- Sehr junges Paket:
@nestjs/storageist bei Version 0.0.x. Für neue Projekte nehme ich es trotzdem, bestehende Uploads würde ich noch nicht sofort umbauen. - Prüfungen nach dem Upload: Pipes und der Handler laufen erst, wenn die Datei schon gespeichert ist. Schlägt dort eine Prüfung fehl, etwa weil es den Datensatz zur ID nicht gibt, musst du
file.keyselbst wieder löschen. Typ und Größe gehören deshalb inuploadToDisk()undlimits. FileTypeValidatorentfällt: Er prüft die Bytes einer Datei im Arbeitsspeicher.uploadToDisk()streamt aber direkt auf die Disk und prüft den Typ schon selbst übercontentTypes.contentTypestattmimetype:StoredUploadhat beide Felder.mimetypeist weiterhin nur die Angabe des Clients. Verlass dich aufcontentType, das ist der aus den Bytes erkannte Typ.- Fehler sind keine
HttpExceptions: Methoden wieget()oderstat()werfen eigene Fehler, zum BeispielStorageFileNotFoundError. Ohne eigenen Exception Filter antwortet die App dann mit500statt404. - Kein eigener Treiber für Google Cloud und Azure: Google Cloud Storage läuft über die S3-kompatible Schnittstelle. Für Azure Blob Storage gibt es aktuell keinen Treiber.
LocalDiskin Production: BeiNODE_ENV=productionloggt das Paket eine Warnung, denn bei mehreren Instanzen sieht jede nur ihren eigenen Ordner.
Fazit
Datei-Uploads waren in NestJS bisher ein Bereich, in dem jedes Projekt seine eigene Lösung hatte, inklusive Aufräumen nach fehlgeschlagener Prüfung. @nestjs/storage nimmt dir genau diesen Teil ab: ungültige Dateien werden gar nicht erst gespeichert, und lokal wie in der Cloud läuft derselbe Code.
Du willst die Datei-Uploads in deinem NestJS-Projekt vereinfachen? Schreib mir.