Bilder aus einem Rich-Text-Editor in ASP.NET Core hochladen

Ein Foto einer Stadt bei Nacht in RichTextEditor, nachdem es in das ASP.NET-Core-Beispiel hochgeladen wurde, darüber die vollständige Symbolleiste

Wenn jemand ein Bild hineinzieht, einfügt oder auswählt, sendet RichTextEditor es als multipart/form-data-POST an Ihren Server und setzt die zurückerhaltene URL in den Text ein. In ASP.NET Core ist das eine Action oder ein Minimal-API-Endpoint, der ein IFormFile empfängt. Diese Seite zeigt beides, was der Server prüft, bevor er etwas speichert, und die Antworten, die Sie mit curl testen können.

Die Seite

Der Editor wird aus /richtexteditor/ geladen. Ein kleines gemeinsames Skript, rte-files.js, verbindet ihn mit dem Server: Es setzt den Upload-Handler des Editors für Drag & Drop, Einfügen und den Dialog „Insert Image“ sowie den, mit dem die Galerie in den geöffneten Ordner hochlädt. Beide senden an /rte/upload und lesen {"url": "..."} aus der Antwort.

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 lässt den Editor eine Datei über 4,000,000 Bytes ablehnen, bevor er sie sendet. Der Server prüft die Größe trotzdem selbst.

Die Controller-Action

Im MVC-Beispiel ist die Route eine Action auf einem [ApiController]. [ApiController] bindet einen Parameter vom Typ IFormFile von sich aus aus dem Formular.

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));

Der Parameter ist absichtlich IFormFile?. Bei aktivierten Nullable Reference Types gilt ein nicht nullbarer Parameter als erforderlich, und ein Request ohne Datei würde die ProblemDetails von [ApiController] erhalten statt des {"error": "..."}, das der Editor anzeigt. Die beiden Attribute senken das Request-Limit nur für diese Action. Größenlimits für Uploads in ASP.NET Core behandelt sie.

Dieselbe Route als Minimal API

Das Minimal-API-Beispiel bildet die Route mit MapPost ab. Das Attribut wird zu Endpoint-Metadaten, und WithFormOptions setzt das Multipart-Limit:

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() ist in .NET 8 nicht optional. Ein Endpoint, der IFormFile oder [FromForm] bindet, trägt Antiforgery-Metadaten. Ohne app.UseAntiforgery() schlug jeder POST daran mit einem 500 fehl („contains anti-forgery metadata, but a middleware was not found“). Mit der Middleware erhielt ein POST ohne Token ein 400. Die Requests des Editors enthalten kein Token, deshalb schaltet das Beispiel die Prüfung ab und prüft stattdessen den Origin des Requests. Sicherer Bild-Upload in ASP.NET Core erklärt diese Prüfung.

Was mit der Datei passiert

Beide Varianten rufen dieselbe Methode auf:

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) };
});
  • Der Ordner ist ein virtueller Pfad wie blog, der unter uploads/ bereits existieren muss. Er wird nie direkt als Pfad auf der Festplatte verwendet.
  • Die Dateiendung muss .jpg, .jpeg, .png, .gif oder .webp sein, und die ersten Bytes müssen zu ihr passen. file.ContentType wird ignoriert: Es ist das, was der Client in den Request geschrieben hat.
  • Der gespeicherte Name ist jedes Mal neu: Aus Red Square.png wird red-square-1a2b3c4d.png. FileMode.CreateNew bedeutet, dass eine vorhandene Datei nie überschrieben wird.
  • Die Dateien werden unter /uploads/... über eine Zuordnung für statische Dateien ausgeliefert, die nur die fünf Bildtypen kennt.

Probleme kommen als JSON mit einem Status zurück, den der Editor anzeigen kann: 400 für eine ungültige Datei oder einen ungültigen Ordner, 403 für einen Cross-Site-Request, 404 für einen fehlenden Ordner, 413 für eine Datei über 4,000,000 Bytes.

Testen

Starten Sie das Beispielprojekt zu dieser Anleitung mit dotnet run (es braucht das .NET 8 SDK) und öffnen Sie http://localhost:5302. Die Upload-Schaltfläche und die Galerie funktionieren beide. Senden Sie in einer Shell ein Foto in den Stammordner und in einen Ordner:

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"}

Das Minimal-API-Beispiel antwortet auf Port 5301 genauso. Ein Ordner, der dort noch nicht existiert, erhält {"error":"Folder not found."} mit einem 404, weil Uploads nie Ordner anlegen. Das tut die Schaltfläche „New folder“ der Galerie.

Dieselbe Anleitung für andere Plattformen

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

Weiterlesen