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

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.
<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>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.
// 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:
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:
// 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
blogthat must already exist underuploads/. It is never used as a disk path directly. - The extension must be
.jpg,.jpeg,.png,.gifor.webp, and the first bytes must match it.file.ContentTypeis ignored: it is whatever the client wrote in the request. - The stored name is new every time:
Red Square.pngbecomesred-square-1a2b3c4d.png.FileMode.CreateNewmeans 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:
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