Traitement du fichier, upload sur S3, suppression de l’ancien avatar, mise à jour du profil.
Dans cette leçon, tu vas apprendre comment :
AwsS3Service
Pour aller plus loin, on réutilise la stack vue en formation (Prisma, NestJS, AWS S3). Si tu as manqué la configuration de ton bucket, reviens à la leçon « Configuration AWS S3 » 🔗AWS S3.
On commence par définir notre schéma Zod pour normaliser le fichier uploadé.
1import { z } from 'zod'23export const fileSchema = z.object({4originalname: z.string(), // nom d’origine5mimetype: z.string(), // type MIME6buffer: z.instanceof(Buffer),// contenu en mémoire7})
On s’assure que file.buffer
soit bien un Buffer
et pas un string vide.
Dans UserController
, on va utiliser FileInterceptor
de NestJS pour recevoir
le fichier en multipart/form-data
.
1import {2Controller, Post, UseInterceptors, UploadedFile, Param,3} from '@nestjs/common'4import { FileInterceptor } from '@nestjs/platform-express'5import { diskStorage } from 'multer'6import { UserService } from './user.service'7import { fileSchema } from 'src/file-utils'89@Controller('users')10export class UserController {11constructor(private readonly userService: UserService) {}1213@Post(':id/avatar')14@UseInterceptors(15FileInterceptor('avatar', {16storage: diskStorage({17destination: './tmp', // stockage temporaire18filename: (_, file, cb) => cb(null, Date.now() + file.originalname),19}),20limits: { fileSize: 2_000_000 }, // 2 Mo max21}),22)23async updateAvatar(24@Param('id') userId: string,25@UploadedFile() file: Express.Multer.File,26) {27// @callout: On valide et on convertit notre fichier28const parsed = fileSchema.parse({29originalname: file.originalname,30mimetype: file.mimetype,31buffer: file.buffer,32})33return this.userService.updateUser({34userId,35submittedFile: parsed,36})37}38}
On utilise un stockage diskStorage
temporaire pour la validation/mutation avant upload S3.
Le service AwsS3Service
encapsule toutes les opérations S3 : upload, getUrl,
delete. Il utilise @aws-sdk/client-s3
et @aws-sdk/s3-request-presigner
.
1import {2PutObjectCommand, DeleteObjectCommand, GetObjectCommand, S3Client,3} from '@aws-sdk/client-s3'4import { getSignedUrl } from '@aws-sdk/s3-request-presigner'5import { createId } from '@paralleldrive/cuid2'6import { fileSchema } from 'src/file-utils'7import { z } from 'zod'89export class AwsS3Service {10private client: S3Client1112constructor() {13this.client = new S3Client({14credentials: {15accessKeyId: process.env.AWS_ACCESS_KEY!,16secretAccessKey: process.env.AWS_SECRET!,17},18region: process.env.AWS_REGION!,19})20}2122async uploadFile({ file }: { file: z.infer<typeof fileSchema> }) {23const key = createId() + file.originalname // ^? clé unique S324const cmd = new PutObjectCommand({25Bucket: process.env.AWS_BUCKET_NAME,26Key: key,27ContentType: file.mimetype,28Body: file.buffer,29CacheControl: 'max-age=31536000',30})31const res = await this.client.send(cmd)32if (res.$metadata.httpStatusCode !== 200) {33console.error(res) // @callout: log en cas d’erreur34}35return { fileKey: key }36}3738async getFileUrl({ fileKey }: { fileKey: string }) {39const cmd = new GetObjectCommand({40Bucket: process.env.AWS_BUCKET_NAME,41Key: fileKey,42})43return getSignedUrl(this.client, cmd)44}4546async deleteFile({ fileKey }: { fileKey: string }) {47const cmd = new DeleteObjectCommand({48Bucket: process.env.AWS_BUCKET_NAME,49Key: fileKey,50})51await this.client.send(cmd)52}53}
Pense à stocker tes secrets AWS dans un vault ou variables d’environnement.
Dans UserService
, l’opération updateUser
:
1import { Injectable } from '@nestjs/common'2import { PrismaService } from 'src/prisma.service'3import { AwsS3Service } from 'src/aws/aws-s3.service'4import { z } from 'zod'5import { fileSchema } from 'src/file-utils'67@Injectable()8export class UserService {9constructor(10private prisma: PrismaService,11private aws: AwsS3Service,12) {}1314async updateUser({15userId,16submittedFile,17}: {18userId: string19submittedFile: z.infer<typeof fileSchema>20}) {21try {22const existing = await this.prisma.user.findUnique({23where: { id: userId },24select: { avatarFileKey: true },25})26if (!existing) throw new Error("L'utilisateur n'existe pas")2728const { fileKey } = await this.aws.uploadFile({ file: submittedFile })29// @callout: mise à jour Prisma30await this.prisma.user.update({31where: { id: userId },32data: { avatarFileKey: fileKey },33})3435if (existing.avatarFileKey) {36await this.aws.deleteFile({ fileKey: existing.avatarFileKey })37}3839return { error: false, message: "Avatar mis à jour avec succès" }40} catch (err) {41const message = err instanceof Error42? err.message43: 'Erreur inattendue'44return { error: true, message }45}46}47}
On renvoie toujours {error, message}
pour un retour uniforme côté frontend.
npm run start:dev
.curl
pour envoyer un POST /users/<id>/avatar
1curl -X POST http://localhost:3000/users/123/avatar \2-F "avatar=@./path/to/avatar.png"
error: false
et que le lien S3 fonctionne.Tu peux ajouter une route GET /users/:id
pour vérifier l’URL pré-signée renvoyée.
image/png
et image/jpeg
.DELETE /users/:id/avatar
qui supprime l’avatar dans S3avatarFileKey
à null
.CacheControl
différent pour les images en miniatures.