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

The Insert Gallery dialog showing the blog, products and team folders and four images from the MVC 5 sample, one selected

The Insert Gallery dialog in RichTextEditor lists the images on your server, opens folders and uploads into the folder that is open. On ASP.NET MVC 5 it needs one controller action that returns JSON for GET /rte/gallery?folder=.... Two MVC defaults get in the way: Json() refuses GET requests, and request validation turns odd folder names into HTML 500 pages. This page shows the action with both handled.

Turn the gallery on

Razor · Views/Home/Index.cshtml
<script>
    var editor = new RichTextEditor("#editor", {
        toolbar: "full",
        galleryEndpoint: "@Url.Content("~/rte/gallery")",
        maxUploadFileSize: 4000000   // same limit as the server
    });
</script>

galleryEndpoint is the URL the dialog asks. maxUploadFileSize repeats the server's 4,000,000-byte limit, so the dialog refuses an oversized file before sending it. The route rte/gallery goes to RteController's Gallery action. It is set up in Global.asax.cs, next to the upload route from image upload in MVC 5.

The action

C# · Controllers/RteController.cs
[HttpGet]
public ActionResult Gallery(string folder)
{
    try
    {
        return JsonStatus(200, RteFiles.Listing(HttpContext, folder));
    }
    catch (RteException ex)
    {
        return JsonStatus(ex.Status, new { error = ex.Message });
    }
}

RteFiles.Listing resolves the virtual folder inside uploads/. It returns the subfolders and the .jpg, .jpeg, .png, .gif and .webp files, sorted by name:

C# · RteFiles.cs
public static object Listing(HttpContextBase context, string folder)
{
    folder = folder ?? "";
    StorageRoot root = GetRoot(context);
    string dir = ResolveFolder(root, folder);

    List<string> folderNames = new List<string>();
    foreach (string path in Directory.GetDirectories(dir))
    {
        string name = Path.GetFileName(path);
        // Skip anything the dialog could not open again (hidden, odd names).
        if (IsValidName(name) && (File.GetAttributes(path) & FileAttributes.Hidden) == 0)
            folderNames.Add(name);
    }
    folderNames.Sort(CompareNames);

    List<FileInfo> files = new List<FileInfo>();
    foreach (FileInfo file in new DirectoryInfo(dir).GetFiles())
    {
        if (Array.IndexOf(ImageExtensions, file.Extension.ToLowerInvariant()) >= 0
            && (file.Attributes & FileAttributes.Hidden) == 0)
            files.Add(file);
    }
    files.Sort(delegate (FileInfo a, FileInfo b) { return CompareNames(a.Name, b.Name); });

    List<object> folders = new List<object>();
    foreach (string name in folderNames)
        folders.Add(new { folder = Combine(folder, name), name = name });

    List<object> images = new List<object>();
    foreach (FileInfo file in files)
        images.Add(new { url = UrlFor(root, folder, file.Name), name = file.Name, folder = folder, size = file.Length });

    string parent = null;
    if (folder.Length > 0)
    {
        int slash = folder.LastIndexOf('/');
        parent = slash < 0 ? "" : folder.Substring(0, slash);
    }

    return new
    {
        currentFolder = folder,
        currentFolderDisplay = "/" + folder,
        parentFolder = parent,
        folders = folders,
        images = images
    };
}

Json() and GET

Json(data) defaults to JsonRequestBehavior.DenyGet. We tried it with a throwaway action. A GET returned HTTP 500 with "This request has been blocked because sensitive information could be disclosed to third party web sites when this is used in a GET request. To allow GET requests, set JsonRequestBehavior to AllowGet." The gallery has to be a GET, so the controller builds its JsonResult in one place:

C# · Controllers/RteController.cs
// Json() refuses GET unless told otherwise (JsonRequestBehavior.DenyGet is the default)
// and caps output at 2,097,152 characters; both are lifted here. Property names are
// written as declared, so the anonymous objects in RteFiles produce camelCase JSON.
JsonResult JsonStatus(int status, object data)
{
    Response.StatusCode = status;
    // Without this, IIS replaces 4xx bodies with its own error page for remote users.
    Response.TrySkipIisCustomErrors = true;
    // Forms authentication would turn a 401 into a 302 to the login page; keep the 401.
    Response.SuppressFormsAuthenticationRedirect = true;
    Response.Cache.SetCacheability(HttpCacheability.NoCache);
    Response.Cache.SetNoStore();
    return new JsonResult
    {
        Data = data,
        JsonRequestBehavior = JsonRequestBehavior.AllowGet,
        MaxJsonLength = int.MaxValue
    };
}
  • AllowGet: MVC's message names the risk, which is another site reading a GET response. The answer here is always an object, never a top-level array. It lists only what the dialog shows anyway. If that list is private, put the route behind sign-in, as in per-user folders below.
  • MaxJsonLength: JsonResult uses JavaScriptSerializer. Per MVC's documentation, a null value means the serializer's 2,097,152-character default, which a folder with thousands of images could pass. We did not measure that limit.
  • JavaScriptSerializer writes names exactly as declared. The anonymous objects in RteFiles use camelCase, so the JSON matches what the dialog expects without any settings.
  • TrySkipIisCustomErrors stops IIS from replacing a 4xx body with its own error page for remote visitors (with errorMode="Custom"). SuppressFormsAuthenticationRedirect matters once you add per-user folders.
The JSON answer of GET /rte/gallery?folder=blog from the MVC 5 sample, with currentFolder, parentFolder, folders and two images
What the dialog receives for the blog folder.

[ValidateInput(false)] and the folder value

MVC runs ASP.NET request validation while it binds string folder. We removed [ValidateInput(false)] from the controller for a test. GET /rte/gallery?folder=<b> then returned an HTML 500 ("A potentially dangerous Request.QueryString value was detected from the client") before the action ran. With the attribute, the value reaches RteFiles, which only accepts plain folder names, and the answer is 400 {"error":"Invalid folder."}. Turning validation off is safe here because nothing from the request is used without passing those rules.

The image URLs

Each image's url is /uploads/<folder>/<file>, built with VirtualPathUtility.ToAbsolute, and IIS serves it as a static file. MVC routing does not get in the way: RouteExistingFiles is false by default, so existing files under /uploads/, /rte-files.js and the editor's /richtexteditor/ folder bypass the routes. Folder names may contain spaces and non-ASCII letters, and each path segment is percent-encoded. IIS Express has no .webp MIME type, so the sample's web.config adds one. Without it a .webp image is listed but its URL answers 404.3.

Test it

Start the sample project for this guide with run.ps1 (port 5402). Create a team folder with the dialog's New folder button, then:

Shell
curl "http://localhost:5402/rte/gallery?folder=team"
{"currentFolder":"team","currentFolderDisplay":"/team","parentFolder":"","folders":[],"images":[]}

curl "http://localhost:5402/rte/gallery?folder=..%2F"
{"error":"Invalid folder."}

HTTP 200, then HTTP 400. In the browser, Insert Gallery shows the folders and images in uploads/.

The same guide for other platforms

JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · Web Forms

Read next