Téléverser des images depuis un éditeur de texte enrichi avec ASP.NET Core

Une photo d’une ville de nuit insérée dans RichTextEditor après son envoi vers l’exemple ASP.NET Core, avec la barre d’outils complète au-dessus

Quand quelqu’un dépose, colle ou choisit une image, RichTextEditor l’envoie à votre serveur par une requête POST multipart/form-data et place dans le texte l’URL qu’il reçoit en retour. Avec ASP.NET Core, il s’agit d’une action ou d’un point de terminaison Minimal API qui reçoit un IFormFile. Cette page présente les deux, ce que le serveur vérifie avant d’enregistrer quoi que ce soit, et les réponses que vous pouvez tester avec curl.

La page

L’éditeur est chargé depuis /richtexteditor/. Un petit script partagé, rte-files.js, le relie au serveur : il définit le gestionnaire d’envoi de l’éditeur pour le glisser-déposer, le collage et la boîte de dialogue Insert Image, ainsi que celui qu’utilise la galerie pour téléverser dans le dossier ouvert. Les deux envoient vers /rte/upload et lisent {"url": "..."} dans la réponse.

Razor · Views/Home/Index.cshtml
<link rel="stylesheet" href="/richtexteditor/rte_theme_default.css" />
<script src="/richtexteditor/rte.js"></script>
<script src="/richtexteditor/plugins/all_plugins.js"></script>
<script src="/rte-files.js"></script>
Razor · Views/Home/Index.cshtml
var editor = new RichTextEditor("#editor", {
    toolbar: "full",
    galleryEndpoint: "/rte/gallery",
    maxUploadFileSize: 4000000 // same limit as the server, checked before the upload starts
});

maxUploadFileSize fait refuser par l’éditeur tout fichier de plus de 4,000,000 octets avant son envoi. Le serveur vérifie quand même la taille lui-même.

L’action du contrôleur

Dans l’exemple MVC, la route est une action d’un [ApiController]. [ApiController] lie de lui-même un paramètre IFormFile depuis le formulaire.

C# · Controllers/RteController.cs
// POST /rte/upload   file, folder
[HttpPost("upload")]
[RequestSizeLimit(RteFiles.MaxRequestBytes)]                              // the whole request body
[RequestFormLimits(MultipartBodyLengthLimit = RteFiles.MaxRequestBytes)]  // the multipart parser
public async Task<IActionResult> Upload(IFormFile? file, [FromForm] string? folder) =>
    Send(await RteFiles.Save(HttpContext, file, folder));

Le paramètre est IFormFile? à dessein. Avec les types référence nullables activés, un paramètre non nullable est considéré comme obligatoire, et une requête sans fichier recevrait le ProblemDetails de [ApiController] au lieu du {"error": "..."} qu’affiche l’éditeur. Les deux attributs abaissent la limite de requête pour cette seule action. Limites de taille d’envoi avec ASP.NET Core les détaille.

La même route en Minimal API

L’exemple Minimal API associe la route avec MapPost. L’attribut devient une métadonnée du point de terminaison, et WithFormOptions définit la limite multipart :

C# · RteEndpoints.cs
rte.MapPost("/upload", async (HttpContext http, IFormFile? file, [FromForm] string? folder) =>
    Json(await RteFiles.Save(http, file, folder)))
    .DisableAntiforgery()
    // Kestrel accepts 30,000,000-byte bodies by default; this route needs ~4 MB.
    .WithMetadata(new RequestSizeLimitAttribute(RteFiles.MaxRequestBytes))
    .WithFormOptions(multipartBodyLengthLimit: RteFiles.MaxRequestBytes);

.DisableAntiforgery() n’est pas facultatif en .NET 8. Un point de terminaison qui lie IFormFile ou [FromForm] porte des métadonnées antifalsification. Sans app.UseAntiforgery(), chaque POST vers ce point de terminaison a échoué avec une erreur 500 (« contains anti-forgery metadata, but a middleware was not found »). Avec, un POST sans jeton a reçu un 400. Les requêtes de l’éditeur ne portent pas de jeton : l’exemple désactive donc cette vérification et vérifie à la place l’origine de la requête. Envoi d’images sécurisé avec ASP.NET Core explique cette vérification.

Ce qu’il advient du fichier

Les deux versions appellent la même méthode :

C# · RteFiles.cs
// POST /rte/upload   file=<image>, folder=<existing folder, optional>
public static Task<RteResult> Save(HttpContext http, IFormFile? file, string? folder) => RunAsync(async () =>
{
    RequireSameOrigin(http.Request);
    var root = RootFor(http);
    folder ??= "";
    var disk = Resolve(root, folder);
    if (file is null || file.Length == 0) throw new RteError(400, "No file was uploaded.");
    if (file.Length > MaxFileBytes) throw new RteError(413, "The image is larger than 4 MB.");

    var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
    if (!Extensions.Contains(ext))
        throw new RteError(400, "Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.");

    // file.ContentType is whatever the client claimed. Check the first bytes instead.
    var head = new byte[12];
    await using (var input = file.OpenReadStream())
        await input.ReadAtLeastAsync(head, head.Length, throwOnEndOfStream: false);
    if (!SignatureMatches(ext, head)) throw new RteError(400, $"The file is not a valid {ext} image.");

    // A new name every time, so an upload never overwrites another file.
    var random = Convert.ToHexString(RandomNumberGenerator.GetBytes(4)).ToLowerInvariant();
    var stored = $"{SafeBaseName(file.FileName)}-{random}{ext}";
    await using (var output = new FileStream(Path.Combine(disk, stored), FileMode.CreateNew))
        await file.CopyToAsync(output);
    return new { url = UrlFor(root, folder, stored) };
});
  • Le dossier est un chemin virtuel comme blog, qui doit déjà exister sous uploads/. Il n’est jamais utilisé directement comme chemin sur le disque.
  • L’extension doit être .jpg, .jpeg, .png, .gif ou .webp, et les premiers octets doivent lui correspondre. file.ContentType est ignoré : il contient ce que le client a écrit dans la requête, quoi que ce soit.
  • Le nom stocké est nouveau à chaque fois : Red Square.png devient red-square-1a2b3c4d.png. Avec FileMode.CreateNew, un fichier existant n’est jamais écrasé.
  • Les fichiers sont servis sous /uploads/... par un mappage de fichiers statiques qui ne connaît que les cinq types d’images.

Les problèmes reviennent en JSON avec un code d’état que l’éditeur peut afficher : 400 pour un fichier ou un dossier invalide, 403 pour une requête intersite, 404 pour un dossier inexistant, 413 pour un fichier de plus de 4,000,000 octets.

Tester

Démarrez le projet d’exemple de ce guide avec dotnet run (il nécessite le SDK .NET 8) et ouvrez http://localhost:5302. Le bouton d’envoi et la galerie fonctionnent tous deux. Depuis un shell, envoyez une photo à la racine puis dans un dossier :

Shell
curl -F "file=@photo.jpg" http://localhost:5302/rte/upload
{"url":"/uploads/photo-d78dc40d.jpg"}

curl -F "file=@photo.jpg" -F "folder=blog" http://localhost:5302/rte/upload
{"url":"/uploads/blog/photo-65fc24fe.jpg"}

L’exemple Minimal API répond de la même façon sur le port 5301. Un dossier qui n’y existe pas encore reçoit {"error":"Folder not found."} avec un 404, car les envois ne créent jamais de dossiers. C’est le bouton New folder de la galerie qui le fait.

Le même guide pour d’autres plateformes

JavaScript · Next.js · PHP · Razor Pages · Blazor · MVC 5 · Web Forms

À lire ensuite