15 - Upload de fichiers : multipart, signed URLs et streaming
Ce que tu vas apprendre
- Comment fonctionne multipart/form-data
- Les limites de taille et la validation de fichiers
- L'approche presigned URL avec S3
- Le streaming pour les gros fichiers
- Le traitement d'images cote serveur
Prerequisites
Avoir lu l'article sur le caching. Savoir ce qu'est un bucket S3 (ou équivalent) est utile mais pas indispensable.
Mon premier upload de fichier en API, c'etait un endpoint qui acceptait du base64 dans un champ JSON. L'image de 2 Mo devenait 2.7 Mo en base64. Le body JSON complet faisait 3 Mo. Le parser JSON chargeait tout en mémoire d'un coup. Avec 50 uploads simultanes, le serveur manquait de RAM. J'ai appris a la dure que JSON et fichiers binaires ne font pas bon menage.
multipart/form-data
Le format standard pour envoyer des fichiers en HTTP. Le body est decoupe en "parts" separees par un boundary :
POST /products/42/images HTTP/1.1
Content-Type: multipart/form-data; boundary=----formdata123
------formdata123
Content-Disposition: form-data; name="title"
Photo principale
------formdata123
Content-Disposition: form-data; name="file"; filename="burger.jpg"
Content-Type: image/jpeg
[donnees binaires]
------formdata123--
Chaque part a son propre Content-Type. Tu peux mixer du texte et des fichiers dans la meme requête. En Express avec multer :
typescriptimport multer from "multer";
const upload = multer({
limits: { fileSize: 5 * 1024 * 1024 }, // 5 Mo max
fileFilter: (req, file, cb) => {
const allowed = ["image/jpeg", "image/png", "image/webp"];
if (allowed.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error("Type de fichier non autorise"));
}
}
});
app.post("/products/:id/images", upload.single("file"), (req, res) => {
// req.file contient le fichier uploade
res.status(201).json({ url: `/images/${req.file.filename}` });
});
La validation, c'est pas optionnel
Ne fais jamais confiance au Content-Type envoye par le client. Un fichier .exe renomme en .jpg aura toujours image/jpeg comme mimetype. Verifie les magic bytes (les premiers octets du fichier) :
typescriptimport { fileTypeFromBuffer } from "file-type";
const buffer = req.file.buffer;
const type = await fileTypeFromBuffer(buffer);
if (!type || !["image/jpeg", "image/png"].includes(type.mime)) {
return res.status(422).json({
type: "https://api.example.com/errors/invalid-file-type",
title: "Invalid File Type",
status: 422,
detail: "Seuls les fichiers JPEG et PNG sont acceptes."
});
}
Limite aussi la taille. Pas seulement dans multer, mais aussi au niveau du reverse proxy (nginx client_max_body_size) pour rejeter les requêtes trop grosses avant qu'elles n'atteignent ton app.
Presigned URLs : le client upload directement
Pour les gros fichiers, faire transiter les donnees par ton serveur est du gaspillage. Avec les presigned URLs, ton serveur généré une URL temporaire et le client upload directement vers S3 :
1. Client -> API : POST /uploads (demande une URL d'upload)
2. API -> Client : { url: "https://s3.../presigned", fields: {...} }
3. Client -> S3 : PUT avec le fichier (ton serveur n'est pas implique)
4. Client -> API : POST /products/42/images { key: "uploads/abc.jpg" }
typescriptimport { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: "eu-west-1" });
app.post("/uploads", async (req, res) => {
const key = `uploads/${crypto.randomUUID()}.jpg`;
const command = new PutObjectCommand({
Bucket: "mon-bucket",
Key: key,
ContentType: "image/jpeg"
});
const url = await getSignedUrl(s3, command, { expiresIn: 300 });
res.json({ url, key });
});
L'URL expire apres 5 minutes. Le client ne peut uploader que le type de fichier prevu. Ton serveur n'a jamais touche au fichier. J'utilise cette approche sur paltemps.fr pour tous les uploads d'images.
Streaming pour les gros fichiers
Si tu dois quand meme faire transiter le fichier par ton serveur (transformation, virus scan), utilise le streaming pour ne pas charger tout en mémoire :
typescriptimport { pipeline } from "stream/promises";
import { createWriteStream } from "fs";
app.post("/uploads/stream", async (req, res) => {
const dest = createWriteStream(`/tmp/${crypto.randomUUID()}`);
await pipeline(req, dest);
res.status(201).json({ message: "Upload termine" });
});
Avec busboy pour parser le multipart en streaming :
typescriptimport busboy from "busboy";
app.post("/uploads", (req, res) => {
const bb = busboy({ headers: req.headers, limits: { fileSize: 50 * 1024 * 1024 } });
bb.on("file", (name, stream, info) => {
const dest = createWriteStream(`/tmp/${info.filename}`);
stream.pipe(dest);
});
bb.on("finish", () => res.status(201).json({ ok: true }));
req.pipe(bb);
});
Traitement d'images
Redimensionner, convertir en WebP, générer des thumbnails. Sharp est la référencé en Node.js :
typescriptimport sharp from "sharp";
async function processImage(input: Buffer) {
const original = await sharp(input)
.resize(1200, 1200, { fit: "inside", withoutEnlargement: true })
.webp({ quality: 80 })
.toBuffer();
const thumbnail = await sharp(input)
.resize(300, 300, { fit: "cover" })
.webp({ quality: 70 })
.toBuffer();
return { original, thumbnail };
}
Pour les gros volumes, deporte le traitement dans une queue (BullMQ, SQS) plutot que de bloquer la requête HTTP. Le client recoit un 202 Accepted et peut poller un endpoint de statut.
Résumé
- Utilise multipart/form-data pour les uploads, jamais du base64 dans du JSON
- Valide le type réel du fichier avec les magic bytes, pas le mimetype déclaré
- Les presigned URLs permettent au client d'uploader directement vers S3
- Utilise le streaming (busboy) pour ne pas charger les gros fichiers en mémoire
- Deporte le traitement d'images dans une queue pour ne pas bloquer les requêtes
Article précédent : Caching Article suivant : Relations entre ressources