In ASP.NET Core you can set up RichTextEditor image uploads and the Insert Gallery dialog in two ways. The RichTextBox package maps an upload endpoint and a folder-browsing gallery endpoint for you, and its tag helper wires both into the page. Your own endpoints take three minimal API routes and suit a plain RichTextEditor page, or a site where you want full control over storage.
How the pieces fit
- Uploading. When someone inserts, pastes or drops an image, the editor calls a JavaScript upload function, which posts the file to your server. The server answers in plain text:
READY:/imageuploads/photo.pngon success, orERROR:messagewith status 500. The URL afterREADY:is what goes into the image’ssrcand is saved with the content. - The gallery. The Insert Gallery dialog shows the images in
config.galleryImages, or, when a gallery endpoint is set, becomes a folder browser with Up, New folder and Upload.
Option A: the RichTextBox package
RichTextBox.AspNetCore wraps the editor in a tag helper and maps its server endpoints:
// Program.cs
builder.Services.AddRichTextBox(options =>
{
options.UploadWebPath = "/uploads"; // the URL images are served from (this is the default)
});
var app = builder.Build();
app.UseStaticFiles();
app.MapRichTextBoxUploads(); // upload: /richtextbox/upload, gallery: /richtextbox/gallery@* A Razor page *@
@addTagHelper *, RichTextBox
<form method="post">
<richtextbox asp-for="Body" />
</form>The tag helper points the gallery at the gallery endpoint and loads the package’s upload script, so:
- Images are saved under
wwwroot/uploadsand inserted as/uploads/…URLs. - The gallery lists that folder, can create sub-folders, and uploads into the folder being browsed.
- The endpoints need the
RichTextBox.liclicence file in the application root. Without it they answerERROR:RichTextBox license not found.
To keep uploads outside wwwroot, set options.UploadPhysicalPath and serve that folder at the same URL:
builder.Services.AddRichTextBox(options =>
{
options.UploadWebPath = "/uploads";
options.UploadPhysicalPath = @"D:\site-uploads";
});
// after builder.Build(); needs using Microsoft.Extensions.FileProviders;
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(@"D:\site-uploads"),
RequestPath = "/uploads",
});options.UploadEndpoint and options.GalleryEndpoint change the endpoint paths, and options.AllowedImageExtensions controls which image types are accepted.
Option B: your own endpoints
For a plain RichTextEditor page, add these endpoints to Program.cs. They are complete: upload, gallery listing, and folder creation.
// ASP.NET Core (.NET 8) endpoints for RichTextEditor image uploads and the Insert Gallery dialog.
//
// POST /rte/upload?name=&type=&size=&folder= raw file body -> "READY:<url>" or "ERROR:<message>"
// GET /rte/gallery?folder=a/b -> folder listing (JSON)
// POST /rte/gallery (form: action=create-folder, folder, name) -> listing of the new folder
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseStaticFiles();
const long MaxBytes = 4_000_000;
const string UploadUrl = "/imageuploads"; // served by UseStaticFiles
string[] imageExtensions = { ".jpg", ".jpeg", ".jfif", ".png", ".gif", ".webp", ".bmp", ".avif" };
string uploadRoot = Path.GetFullPath(Path.Combine(app.Environment.WebRootPath, "imageuploads"));
Directory.CreateDirectory(uploadRoot);
// In a real application, add .RequireAuthorization() to all three endpoints.
app.MapPost("/rte/upload", async (HttpRequest request, string? name, string? folder) =>
{
string ext = Path.GetExtension(name ?? "").ToLowerInvariant();
if (!imageExtensions.Contains(ext)) return Error("invalid file extension");
if (request.ContentLength is null or > MaxBytes) return Error("file too big");
string relative = Normalize(folder);
string? dir = ResolveFolder(relative);
if (dir is null) return Error("invalid folder");
using var buffer = new MemoryStream();
await request.Body.CopyToAsync(buffer);
byte[] data = buffer.ToArray();
if (data.Length == 0 || data.Length > MaxBytes) return Error("file too big");
if (!LooksLikeImage(data, ext)) return Error("file contents do not match its type");
// Never reuse the uploaded file name: it is chosen by whoever sends the request.
string fileName = $"{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}{ext}";
await File.WriteAllBytesAsync(Path.Combine(dir, fileName), data);
return Results.Text("READY:" + BuildUrl(relative, fileName), "text/plain");
});
app.MapGet("/rte/gallery", (string? folder) =>
{
string relative = Normalize(folder);
if (ResolveFolder(relative) is null) relative = "";
return Results.Json(BuildPayload(relative));
});
app.MapPost("/rte/gallery", async (HttpRequest request) =>
{
var form = await request.ReadFormAsync();
if (form["action"] != "create-folder") return Results.BadRequest(new { error = "unsupported action" });
string parent = Normalize(form["folder"]);
string name = form["name"].ToString().Trim();
if (ResolveFolder(parent) is null || !IsValidName(name)) return Results.BadRequest(new { error = "invalid folder name" });
string relative = parent.Length == 0 ? name : parent + "/" + name;
Directory.CreateDirectory(Path.Combine(uploadRoot, relative));
return Results.Json(BuildPayload(relative));
});
app.Run();
string Normalize(string? folder) => (folder ?? "").Replace('\\', '/').Trim('/');
bool IsValidName(string part) =>
part.Length > 0 && part != "." && part != ".." && part.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
// The physical directory for "a/b" inside uploadRoot, or null if it is invalid, missing, or outside it.
string? ResolveFolder(string relative)
{
if (relative.Length == 0) return uploadRoot;
if (relative.Split('/').Any(part => !IsValidName(part))) return null;
string full = Path.GetFullPath(Path.Combine(uploadRoot, relative));
bool inside = full.StartsWith(uploadRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
return inside && Directory.Exists(full) ? full : null;
}
string BuildUrl(string relative, string fileName) =>
UploadUrl + "/" + string.Join("/",
(relative.Length == 0 ? Array.Empty<string>() : relative.Split('/')).Append(fileName).Select(Uri.EscapeDataString));
object BuildPayload(string relative)
{
string dir = ResolveFolder(relative) ?? uploadRoot;
int slash = relative.LastIndexOf('/');
return new
{
currentFolder = relative,
currentFolderDisplay = "/" + relative,
parentFolder = relative.Length == 0 ? null : (slash < 0 ? "" : relative[..slash]),
folders = Directory.GetDirectories(dir).Select(Path.GetFileName)
.Select(n => new { name = n, folder = relative.Length == 0 ? n : relative + "/" + n }),
images = Directory.GetFiles(dir)
.Where(f => imageExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.Select(f => new FileInfo(f))
.Select(f => new { name = f.Name, folder = relative, size = f.Length, url = BuildUrl(relative, f.Name) }),
};
}
bool LooksLikeImage(byte[] b, string ext)
{
bool At(int offset, string ascii) =>
b.Length >= offset + ascii.Length && System.Text.Encoding.ASCII.GetString(b, offset, ascii.Length) == ascii;
return ext switch
{
".jpg" or ".jpeg" or ".jfif" => b.Length > 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF,
".png" => b.Length > 8 && b[0] == 0x89 && At(1, "PNG"),
".gif" => At(0, "GIF8"),
".webp" => At(0, "RIFF") && At(8, "WEBP"),
".bmp" => At(0, "BM"),
".avif" => At(4, "ftyp"),
_ => false,
};
}
static IResult Error(string message) => Results.Text("ERROR:" + message, "text/plain", statusCode: 500);What they do on purpose:
- Images only: JPG, JPEG, JFIF, PNG, GIF, WebP, BMP and AVIF, the same list the gallery’s file picker offers. SVG is left out because an SVG file can contain script, which would then run from your own domain. The ASP.NET Core sample’s
RTEUploadControlleraccepts documents and only JPEG and PNG images, so replace it. - Check the first bytes of each file, so a renamed executable is refused.
- Generate the file name, and never use the uploaded name on disk.
- Refuse any folder outside
wwwroot/imageuploads, including..and backslash tricks.
Add .RequireAuthorization() to all three before you ship. Without a sign-in check, anyone can store files on your server.
Register them on the page
Copy rte-upload.js from the ASP.NET Core sample into wwwroot, and set the handler URL on its first line: var uploadhandlerpath = "/rte/upload";
<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-upload.js"></script> <!-- defines rte_file_upload_handler -->
<div id="div_editor1"></div>
<script>
var config = {};
config.galleryEndpoint = "/rte/gallery";
var editor1 = new RichTextEditor("#div_editor1", config);
</script>rte-upload.js defines a global function, rte_file_upload_handler. Keep it global. A per-editor config.file_upload_handlerfunction also works for the image dialog, paste and drag-and-drop, but the gallery’s Upload button only looks for the global function.
To upload into the folder being browsed rather than the top-level folder, also define window.richTextBoxUploadFile. The gallery prefers it and passes the current folder:
// Lets the Insert Gallery dialog upload into the folder being browsed.
// Without this, the gallery's Upload button uses rte_file_upload_handler and
// every file lands in the top-level upload folder.
window.richTextBoxUploadFile = function (file, callback, options) {
var folder = (options && options.folder) || "";
var xh = new XMLHttpRequest();
xh.open("POST", "/rte/upload?name=" + encodeURIComponent(file.name) +
"&type=" + encodeURIComponent(file.type) + "&size=" + file.size +
"&folder=" + encodeURIComponent(folder), true);
xh.onload = function () {
var text = xh.responseText || "";
if (xh.status === 200 && text.indexOf("READY:") === 0) callback(text.substring(6));
else callback(null, text.indexOf("ERROR:") === 0 ? text.substring(6) : "http-" + xh.status);
};
xh.onerror = function () { callback(null, "network-error"); };
xh.send(file);
};Where images are saved, and which URL is stored
- The folder is
wwwroot/imageuploads(uploadRoot), served byUseStaticFiles. The account running the site needs write permission on it. - The URL is root-relative, such as
/imageuploads/Products/photo.png, so it works on every page that shows the content. If the application runs under a path base such as/intranet, addrequest.PathBaseto the URL the upload endpoint returns, and to the URLs inrte-upload.jsandgalleryEndpoint.
When the content is displayed on another domain, such as in an email, it needs full URLs. The urlType option rewrites every src and href in the HTML the editor returns:
var config = {};
config.urlType = "absolute"; // "default" | "absolute" | "relative""absolute" turns /imageuploads/a.png into https://your-site/imageuploads/a.png, using the address of the page the editor is on; "relative" does the reverse for full URLs on the same site.
Size limits. The endpoint refuses files over 4,000,000 bytes. Kestrel allows request bodies of about 28.6 MB by default, and so does IIS (maxAllowedContentLength), so raising MaxBytes is enough up to that size.
The gallery endpoint contract
Whether you use the package or your own endpoint, the dialog makes two requests: GET ?folder=Products to list a folder, and a POST with form fields action=create-folder, folder and name to create one. Both return:
{
"currentFolder": "Products",
"currentFolderDisplay": "/Products",
"parentFolder": "",
"folders": [ { "name": "Summer 2026", "folder": "Products/Summer 2026" } ],
"images": [ { "name": "a.png", "folder": "Products", "size": 9, "url": "/imageuploads/Products/a.png" } ]
}parentFolder is null at the top level, which disables Up. If the endpoint fails, the dialog reports it and falls back to galleryImages.
A fixed list instead
With no gallery endpoint, the dialog shows config.galleryImages. Replace the default: out of the box it lists six demo pictures hosted on richtexteditor.com.
var config = {};
config.galleryImages = [
"/imageuploads/logo.png",
["/imageuploads/team.jpg", "Team photo", "Taken March 2026"],
{ url: "/imageuploads/banner.webp", thumbnail: "/imageuploads/thumbs/banner.webp", name: "Homepage banner" }
];Each entry is a URL, an array of URL, name and description, or an object with url and optional thumbnail, name and meta.
Checklist
- Package:
MapRichTextBoxUploads(),UseStaticFiles()andRichTextBox.lic. - Own endpoints: the handler URL set in
rte-upload.js, andgalleryEndpointset. .RequireAuthorization()on every upload and gallery endpoint.- Root-relative image URLs, including any path base.
- The same accepted formats as the gallery, and no SVG.
The Option B endpoints were run on .NET 9 and tested over HTTP: an image upload served back byte for byte, SVG, PDF and fake-image uploads refused, folder traversal refused, folder creation, uploads into a sub-folder, and the gallery JSON. They use nothing newer than .NET 8.
Related: the Web Forms and MVC 5 version of this guide, what an upload handler has to get right, and the upload handler reference.
RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.