Uploads de fichiers sécurisés avec Claude Code : FormData, validation, aperçu et S3
Guide SaaS pour upload sécurisé avec Claude Code : File API, FormData, fetch, validation, progression et S3.
L’upload de fichiers semble simple tant qu’il reste un bouton dans une maquette. Dans un vrai SaaS, il devient vite une frontière de sécurité. Photo de profil, facture PDF, import CSV, contrat, pièce jointe de chat : tous ces cas commencent par “choisir un fichier”, mais demandent ensuite API navigateur, format d’envoi, validation serveur, stockage, permissions, aperçu, progression, coûts et journalisation.
Claude Code peut beaucoup aider. Il peut générer le composant React, la route API, le validateur, l’adaptateur de stockage et le README. Mais il faut cadrer la demande. Si le prompt dit seulement “fais un upload de fichier”, le résultat peut marcher en démo tout en gardant le nom original, en faisant trop confiance au MIME du navigateur, en oubliant la limite de taille ou en exposant une URL publique trop tôt.
Ce guide suit un ordre facile à relire. On sépare d’abord File API, FormData et Fetch API. Ensuite on construit un upload minimal, un aperçu et une progression réelle en React, une validation serveur, puis on décide quand déplacer les fichiers vers S3, Cloud Storage, Azure Blob Storage ou Cloudflare R2.
Pour la partie S3, lis aussi Claude Code et AWS S3. Pour la posture de revue sécurité, garde sous la main les bonnes pratiques de sécurité Claude Code.
Séparer les responsabilités côté navigateur
La première brique est File API. Quand l’utilisateur choisit un fichier avec<input type="file">ou le dépose dans la page, le navigateur fournit un objetFile. Tu peux lirefile.name, file.size, file.type et file.lastModified. Pour une image,URL.createObjectURL(file) permet de créer une URL temporaire d’aperçu.
La deuxième brique est FormData. C’est le conteneur qui envoie champs et fichiers enmultipart/form-data. On ajoute le fichier avecformData.append("file", file). Point pratique : avec FormData et fetch, ne fixe pas toi-même l’en-têteContent-Type. Le navigateur doit ajouter la boundary multipart ; l’écrire à la main peut casser la requête.
La troisième brique est fetch. Pour un upload simple, c’est suffisant :
const formData = new FormData();
formData.append("file", file);
await fetch("/api/upload", {
method: "POST",
body: formData
});
La limite apparaît avec la progression. Fetch est très propre pour envoyer la requête, mais les événements de progression d’upload restent plus directs avecXMLHttpRequest.upload.onprogress. Dans mes prompts, je demande donc fetch pour le chemin simple et XMLHttpRequest si le produit exige une vraie barre de progression.
Implémentation minimale en HTML et fetch
Avant React et S3, vérifie le flux de base : sélection, validation côté navigateur, aperçu, envoi FormData.
<form id="upload-form">
<input id="file-input" name="file" type="file" accept="image/png,image/jpeg,application/pdf" />
<button type="submit">Uploader</button>
</form>
<img id="preview" alt="" style="max-width: 240px; display: none;" />
<p id="message"></p>
<script type="module">
const MAX_BYTES = 5 * 1024 * 1024;
const allowedTypes = new Set(["image/png", "image/jpeg", "application/pdf"]);
const form = document.querySelector("#upload-form");
const input = document.querySelector("#file-input");
const preview = document.querySelector("#preview");
const message = document.querySelector("#message");
input.addEventListener("change", () => {
const file = input.files?.[0];
preview.style.display = "none";
preview.removeAttribute("src");
message.textContent = "";
if (!file) return;
if (!allowedTypes.has(file.type)) {
message.textContent = "Seuls PNG, JPEG et PDF sont acceptés.";
input.value = "";
return;
}
if (file.size > MAX_BYTES) {
message.textContent = "Le fichier doit faire 5 Mo ou moins.";
input.value = "";
return;
}
if (file.type.startsWith("image/")) {
preview.src = URL.createObjectURL(file);
preview.style.display = "block";
preview.onload = () => URL.revokeObjectURL(preview.src);
}
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const file = input.files?.[0];
if (!file) {
message.textContent = "Choisis d'abord un fichier.";
return;
}
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData
});
const result = await response.json();
message.textContent = response.ok ? `Enregistré : ${result.name}` : result.error;
});
</script>
Cette validation côté client est une aide UX, pas une barrière de sécurité. Un client malveillant peut l’ignorer. Le serveur doit refaire les vérifications.
React avec aperçu et progression réelle
En React, garde les états séparés : fichier choisi, URL d’aperçu, progression, erreur et nom enregistré. Claude Code pourra ensuite ajouter drag and drop, annulation ou retry sans tout mélanger.
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Set(["image/png", "image/jpeg", "application/pdf"]);
type UploadResult = { ok: true; name: string; size: number; type: string };
export function FileUploadBox() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const [uploadedName, setUploadedName] = useState<string | null>(null);
const canUpload = useMemo(() => selectedFile && !error, [selectedFile, error]);
useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
}, [previewUrl]);
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0] ?? null;
setUploadedName(null);
setProgress(0);
setError(null);
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
if (!file) return setSelectedFile(null);
if (!ALLOWED_TYPES.has(file.type)) {
setSelectedFile(null);
return setError("Seuls PNG, JPEG et PDF sont acceptés.");
}
if (file.size > MAX_BYTES) {
setSelectedFile(null);
return setError("Le fichier doit faire 5 Mo ou moins.");
}
setSelectedFile(file);
if (file.type.startsWith("image/")) setPreviewUrl(URL.createObjectURL(file));
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!selectedFile) return;
const formData = new FormData();
formData.append("file", selectedFile);
const result = await uploadWithProgress(formData, setProgress);
setUploadedName(result.name);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<input type="file" accept="image/png,image/jpeg,application/pdf" onChange={handleFileChange} />
{previewUrl && <img src={previewUrl} alt="Aperçu du fichier choisi" width={240} />}
{selectedFile && <p>{selectedFile.name} / {Math.round(selectedFile.size / 1024)}KB</p>}
{error && <p role="alert">{error}</p>}
<progress value={progress} max={100}>{progress}%</progress>
<button type="submit" disabled={!canUpload}>Uploader</button>
{uploadedName && <p>Enregistré : {uploadedName}</p>}
</form>
);
}
function uploadWithProgress(formData: FormData, onProgress: (progress: number) => void) {
return new Promise<UploadResult>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload");
xhr.upload.addEventListener("progress", (event) => {
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100));
});
xhr.addEventListener("load", () => {
const body = JSON.parse(xhr.responseText || "{}");
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
else reject(new Error(body.error ?? "Upload failed"));
});
xhr.addEventListener("error", () => reject(new Error("Network error")));
xhr.send(formData);
});
}
Si tu ne mesures pas vraiment la progression, affiche seulement “upload en cours”. Une fausse barre de progression est jolie en démo, mais elle casse la confiance sur un réseau lent.
Validation serveur vérifiable
Cette route Next.js stocke dans.local-uploads pour la première version. En production, remplace cette écriture par un adaptateur S3, Cloud Storage, Azure Blob Storage ou R2.
// app/api/upload/route.ts
import { randomUUID } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED_TYPES = new Map([
["image/png", ".png"],
["image/jpeg", ".jpg"],
["application/pdf", ".pdf"]
]);
export async function POST(request: NextRequest) {
const formData = await request.formData();
const value = formData.get("file");
if (!(value instanceof File)) {
return NextResponse.json({ error: "Fichier manquant." }, { status: 400 });
}
const expectedExt = ALLOWED_TYPES.get(value.type);
const originalExt = path.extname(value.name).toLowerCase();
if (!expectedExt) return NextResponse.json({ error: "Type MIME non autorisé." }, { status: 400 });
if (value.size === 0 || value.size > MAX_BYTES) {
return NextResponse.json({ error: "Le fichier doit faire entre 1 octet et 5 Mo." }, { status: 400 });
}
if (expectedExt === ".jpg" && ![".jpg", ".jpeg"].includes(originalExt)) {
return NextResponse.json({ error: "Extension JPEG invalide." }, { status: 400 });
}
if (expectedExt !== ".jpg" && originalExt !== expectedExt) {
return NextResponse.json({ error: "Le MIME et l'extension ne correspondent pas." }, { status: 400 });
}
const bytes = Buffer.from(await value.arrayBuffer());
const storedName = `${randomUUID()}${expectedExt === ".jpg" ? ".jpg" : expectedExt}`;
const uploadDir = path.join(process.cwd(), ".local-uploads");
await mkdir(uploadDir, { recursive: true });
await writeFile(path.join(uploadDir, storedName), bytes, { flag: "wx" });
return NextResponse.json({ ok: true, name: storedName, size: value.size, type: value.type });
}
À relire : MIME, extension, taille, nom de stockage, emplacement. Le nom original reste une métadonnée, pas une clé. Pour des documents sensibles, ajoute signature magique, décodage image, scan antivirus, authentification, quotas et logs d’audit.
Quand passer à S3 ou Cloud Storage
Le stockage local convient aux prototypes. Pour des fichiers clients, plusieurs serveurs, des règles de durée de vie, des miniatures asynchrones ou des téléchargements privés, passe à un object storage.
Tu n’as pas toujours besoin de faire un upload direct navigateur vers S3 dès le premier jour. Pour de petits fichiers, le serveur peut recevoir, valider puis envoyer vers S3. Les presigned URLs deviennent utiles quand la taille ou le trafic le justifie.
Convertis l'upload local Next.js existant vers un stockage S3.
État actuel : /api/upload reçoit FormData et valide déjà MIME, extension et taille.
Contraintes : ne pas utiliser le nom original comme S3 key. Stocker sous uploads/yyyy/mm/dd/{uuid}.ext.
Contraintes : autoriser seulement image/png, image/jpeg et application/pdf. Taille max 5 Mo.
Contraintes : bucket privé. L'API retourne un ID de fichier, pas une URL publique.
Livrables : app/api/upload/route.ts, lib/storage/s3.ts, tests d'échec, README des variables d'environnement.
Vérification : expliquer les tests fichier trop gros, extension incohérente, non connecté et succès.
Cas d’usage et erreurs fréquentes
Cas 1 : image de profil. L’aperçu, le recadrage et le retry comptent. Commence avec PNG, JPEG et WebP, et traite SVG avec prudence.
Cas 2 : import CSV. L’upload n’est que l’entrée. Il faut vérifier colonnes, encodage, nombre de lignes, doublons, rollback et rapport d’erreurs.
Cas 3 : factures et contrats PDF. Ne retourne pas une URL publique. Stocke en privé, vérifie l’organisation et l’utilisateur, puis émet une URL signée courte.
Erreurs classiques : prendreacceptpour une sécurité, sauvegarder le nom original, publier une URL trop tôt, afficher une fausse progression, donner trop de droits S3.
Prompt Claude Code et note de Masa
Ajoute un upload de fichiers sécurisé à cette app Next.js.
Objectif : écran admin SaaS qui uploade un PNG, JPEG ou PDF à la fois.
Client : composant React avec File API, nom, taille, aperçu image, erreur et état d'upload.
Transport : POST FormData vers /api/upload. Si une vraie progression est requise, utiliser XMLHttpRequest et expliquer pourquoi fetch ne suffit pas.
Serveur : dans app/api/upload/route.ts, recevoir FormData et valider MIME, extension, limite 5 Mo et fichier vide.
Stockage : ne pas utiliser le nom original. Sauvegarder UUID + extension dans .local-uploads.
Interdits : ne pas écrire directement dans public/. Ne pas dire que l'extension suffit à sécuriser. Ne pas rendre un bucket S3 public.
Vérification : expliquer tests taille excessive, extension incohérente, fichier absent et succès.
Références : MDN File API, FormData et Fetch API.
Dans mes essais, la qualité a nettement augmenté quand j’ai séparé “upload simple avec fetch” et “upload avec vraie progression”. Sinon, Claude Code peut produire une barre jolie mais non reliée au transfert réel. Commencer par le stockage local aide aussi : on valide File API, FormData, la route serveur et l’aperçu avant d’ajouter IAM, CORS et URLs signées.
Résumé
Un upload sécurisé n’est pas seulement un champ de formulaire. C’est une frontière entre l’appareil de l’utilisateur, ton serveur, ton stockage et ton modèle de droits. Claude Code peut l’implémenter vite si le prompt précise File API, FormData, validation serveur, limite de taille, clé générée, progression honnête et stockage privé.
Pour adapter ce modèle à un dépôt réel, la formation et consultation Claude Code peut couvrir le flux d’upload, S3, les URLs signées, les tests et la checklist de revue. Les PDF gratuits et supports d’apprentissage sont une bonne première étape.
PDF gratuit: cheatsheet Claude Code
Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.
Nous protégeons vos données et n'envoyons pas de spam.
À propos de l'auteur
Masa
Ingénieur spécialisé dans les workflows pratiques avec Claude Code.
Articles liés
Workflow Obsidian vers CLAUDE.md avec Claude Code
Transformer des notes Obsidian en notes CLAUDE.md concises pour reprendre les sessions sans réexpliquer.
Claude Code Revenue CTA Routing : relier articles, PDF, Gumroad et consultation
Un workflow Claude Code pour orienter les lecteurs vers PDF gratuit, Gumroad ou consultation selon l'intention.
Règles de handoff Claude Code en équipe: preuves, permissions, rollback et revenus
Un format concret pour transmettre un travail Claude Code avec preuves, permissions, rollback, PDF gratuit, Gumroad et consultation.