Add an image gallery to a rich text editor in ASP.NET Core

The Insert Gallery dialog showing the blog, products and team folders and four photos from the ASP.NET Core sample's uploads folder, one selected

An upload button only adds files. The Insert Gallery dialog also shows what is already on the server, lets people open folders, create new ones and upload into the open folder. In ASP.NET Core the dialog needs one GET route that lists a folder as JSON, one POST route for new folders and the upload route. This page covers the listing and how the files are served.

The button and the option

The full toolbar already has the Insert Gallery button (insertgallery). Setting galleryEndpoint switches the dialog from a fixed list of preset images to your server:

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

The dialog appends ?folder= to that URL to list a folder, and posts action=create-folder to the same URL for the New folder button. Uploads go through rte-files.js to /rte/upload.

The route

C# · Controllers/RteController.cs
// GET /rte/gallery?folder=products
[HttpGet("gallery")]
public IActionResult Gallery([FromQuery] string? folder) =>
    Send(RteFiles.List(HttpContext, folder));

In the Minimal API sample the same line is rte.MapGet("/gallery", (HttpContext http, string? folder) => ...). Both call RteFiles.List:

C# · RteFiles.cs
// GET /rte/gallery?folder=products
public static RteResult List(HttpContext http, string? folder) => Run(() =>
{
    var root = RootFor(http);
    folder ??= "";
    return Listing(root, folder, Resolve(root, folder));
});

Resolve turns the virtual folder into a path inside uploads/ or answers 400 or 404. Gallery folders in ASP.NET Core explains it.

The JSON the dialog reads

C# · RteFiles.cs
static RteListing Listing(RteRoot root, string folder, string disk)
{
    var dir = new DirectoryInfo(disk);
    var folders = dir.EnumerateDirectories()
        .Where(d => IsValidName(d.Name))
        .OrderBy(d => d.Name.ToLowerInvariant(), StringComparer.Ordinal)
        .Select(d => new RteFolder(folder.Length == 0 ? d.Name : folder + "/" + d.Name, d.Name))
        .ToList();
    var images = dir.EnumerateFiles()
        .Where(f => !f.Name.StartsWith('.') && Extensions.Contains(f.Extension.ToLowerInvariant()))
        .OrderBy(f => f.Name.ToLowerInvariant(), StringComparer.Ordinal)
        .Select(f => new RteImage(UrlFor(root, folder, f.Name), f.Name, folder, f.Length))
        .ToList();
    string? parent = folder.Length == 0 ? null : folder.Contains('/') ? folder[..folder.LastIndexOf('/')] : "";
    return new RteListing(folder, "/" + folder, parent, folders, images);
}
The JSON the ASP.NET Core sample returns for GET /rte/gallery?folder=blog: currentFolder, currentFolderDisplay, parentFolder, an empty folders list and images with url, name, folder and size
The real answer for the blog folder, shortened to two images.
  • parentFolder is null at the root, which turns off the dialog's Up button. In a folder it is the parent's virtual path, "" for a top-level folder.
  • The properties come out camelCase because ASP.NET Core's JSON defaults are camelCase, both for Results.Json and for MVC's ObjectResult.
  • Only the five image types are listed. Folders whose names fail the folder rules, such as .git, are skipped.
  • Names are sorted by their lowercase form with an ordinal comparison. StringComparer.OrdinalIgnoreCase compares upper case, so it orders a_b and aab differently from the browser.
  • Each URL segment is escaped with Uri.EscapeDataString, so a folder called My photos still gives a working URL.

Serving the pictures

The URLs point at /uploads/, served by a second UseStaticFiles call with its own content type list:

C# · RteEndpoints.cs
var uploads = RteFiles.UploadsFolder(app.Environment);
Directory.CreateDirectory(uploads);
app.UseStaticFiles(new StaticFileOptions
{
    FileProvider = new PhysicalFileProvider(uploads),
    RequestPath = "/uploads",
    ContentTypeProvider = new FileExtensionContentTypeProvider(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
    {
        [".jpg"] = "image/jpeg", [".jpeg"] = "image/jpeg", [".png"] = "image/png",
        [".gif"] = "image/gif", [".webp"] = "image/webp",
    }),
    OnPrepareResponse = ctx => ctx.Context.Response.Headers.XContentTypeOptions = "nosniff",
});

The list matters. With the default FileExtensionContentTypeProvider, an .html or .svg file in the folder would be served as text/html or image/svg+xml. Turning ServeUnknownFileTypes off, which is the default, only blocks extensions the provider does not know. With the sample's list, a probe.html and a probe.svg we put in uploads/ both returned 404.

What the dialog does today

The released editor's dialog says "Accepted formats: JPG, JPEG, JFIF, PNG, GIF, WEBP, BMP, AVIF". This server accepts five of them. A .bmp is sent, the server answers 400, and the dialog shows its own "Upload failed for pic.bmp." rather than the server's message. A file over maxUploadFileSize is refused in the browser with "big.jpg is 4.3 MB. The maximum allowed size is 3.8 MB." The dialog counts in binary megabytes.

Test it

Run the sample project for this guide with dotnet run, open http://localhost:5302 and click Insert Gallery. To see the JSON without the dialog:

Shell
curl "http://localhost:5302/rte/gallery?folder=blog%2F2026"
{"currentFolder":"blog/2026","currentFolderDisplay":"/blog/2026","parentFolder":"blog","folders":[],"images":[]}

That folder was created with the New folder button. A folder that does not exist answers 404 {"error":"Folder not found."}.

The same guide for other platforms

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

Read next