Upload images from a rich text editor in ASP.NET Core

A photo of a city at night inserted into RichTextEditor after it was uploaded to the ASP.NET Core sample, with the full toolbar above it

When someone drops, pastes or picks a picture, RichTextEditor sends it to your server as a multipart/form-data POST and puts the URL it gets back into the text. In ASP.NET Core that is one action or one Minimal API endpoint that receives an IFormFile. This page shows both, what the server checks before it saves anything, and the answers you can test with curl.

The page

The editor loads from /richtexteditor/. One small shared script, rte-files.js, connects it to the server: it sets the editor's upload handler for drag and drop, paste and the Insert Image dialog, and the one the gallery uses to upload into the open folder. Both post to /rte/upload and read {"url": "..."} from the answer.

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 makes the editor refuse a file over 4,000,000 bytes before sending it. The server still checks the size itself.

The controller action

In the MVC sample the route is an action on an [ApiController]. [ApiController] binds an IFormFile parameter from the form by itself.

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

The parameter is IFormFile? on purpose. With nullable reference types on, a non-nullable parameter is treated as required, and a request without a file would get [ApiController]'s ProblemDetails instead of the {"error": "..."} the editor shows. The two attributes lower the request limit for this action only. Upload size limits in ASP.NET Core covers them.

The same route as a Minimal API

The Minimal API sample maps the route with MapPost. The attribute becomes endpoint metadata, and WithFormOptions sets the 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() is not optional in .NET 8. An endpoint that binds IFormFile or [FromForm] carries antiforgery metadata. Without app.UseAntiforgery() every POST to it failed with a 500 ("contains anti-forgery metadata, but a middleware was not found"). With it, a POST without a token got a 400. The editor's requests carry no token, so the sample turns the check off and checks the request's origin instead. Secure image upload in ASP.NET Core explains that check.

What happens to the file

Both versions call the same method:

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) };
});
  • The folder is a virtual path such as blog that must already exist under uploads/. It is never used as a disk path directly.
  • The extension must be .jpg, .jpeg, .png, .gif or .webp, and the first bytes must match it. file.ContentType is ignored: it is whatever the client wrote in the request.
  • The stored name is new every time: Red Square.png becomes red-square-1a2b3c4d.png. FileMode.CreateNew means an existing file is never overwritten.
  • The files are served at /uploads/... by a static files mapping that only knows the five image types.

Problems come back as JSON with a status the editor can show: 400 for a bad file or folder, 403 for a cross-site request, 404 for a missing folder, 413 for a file over 4,000,000 bytes.

Test it

Start the sample project for this guide with dotnet run (it needs the .NET 8 SDK) and open http://localhost:5302. The upload button and the gallery both work. From a shell, send a photo to the root and to a folder:

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

The Minimal API sample answers the same way on port 5301. A folder that does not exist there yet gets {"error":"Folder not found."} with a 404, because uploads never create folders. The gallery's New folder button does.

The same guide for other platforms

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

Read next