Tutorial

Image uploads and the gallery in ASP.NET Web Forms and MVC

Where uploaded images are saved, which URL goes into the content, and how to give the Insert Gallery dialog a folder browser. Tested handler code for Web Forms and MVC 5.

10 min read

Three things decide how images behave in RichTextEditor on an ASP.NET site: the upload handler that receives a file and answers with its URL, the folder and URL that handler uses, and where the Insert Gallery dialog gets its images, either a fixed list or an endpoint that browses a folder on your server. This guide sets up all three for Web Forms and MVC 5.

The ASP.NET samples in the download already include an upload handler ( RTEUpload.ashx in Web Forms, RTEUploadController in MVC) and a client script, rte-upload.js. Neither includes a gallery endpoint, and both handlers accept documents while accepting only a few image formats. The code below replaces them with an images-only handler that the gallery also uses.

How the pieces fit

1. The upload handler

The handler and the gallery share one helper class, so they agree on where images live and which folder names are allowed. In a Web Forms Web Site project, put it in App_Code. In a Web Application project or MVC, add it anywhere in the project.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;

// Shared by the upload handler and the gallery endpoint, so both agree on where
// images live, which folder names are allowed, and what the gallery JSON looks like.
// Web Forms: put this file in App_Code. MVC: add it anywhere in the project.
public static class RteImageFolder
{
    public static readonly string[] ImageExtensions = { ".jpg", ".jpeg", ".jfif", ".png", ".gif", ".webp", ".bmp", ".avif" };

    public static string Normalize(string folder)
    {
        return (folder ?? "").Replace('\\', '/').Trim('/');
    }

    public static bool IsValidName(string name)
    {
        return name.Length > 0 && name != "." && name != ".." && name.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
    }

    // The physical directory for "a/b" inside root, or null if it is invalid, missing, or outside root.
    public static string Resolve(string root, string folder)
    {
        if (folder.Length == 0) return root;
        if (folder.Split('/').Any(part => !IsValidName(part))) return null;
        string full = Path.GetFullPath(Path.Combine(root, folder.Replace('/', Path.DirectorySeparatorChar)));
        string prefix = root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
        return full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) && Directory.Exists(full) ? full : null;
    }

    public static string BuildUrl(string baseUrl, string folder, string fileName)
    {
        var parts = new List<string>();
        if (folder.Length > 0) parts.AddRange(folder.Split('/'));
        parts.Add(fileName);
        return baseUrl.TrimEnd('/') + "/" + string.Join("/", parts.Select(p => Uri.EscapeDataString(p)));
    }

    // The JSON shape the Insert Gallery dialog expects from config.galleryEndpoint.
    public static object BuildPayload(string root, string baseUrl, string folder)
    {
        string dir = Resolve(root, folder);
        if (dir == null) { folder = ""; dir = root; }
        int slash = folder.LastIndexOf('/');
        return new
        {
            currentFolder = folder,
            currentFolderDisplay = "/" + folder,
            parentFolder = folder.Length == 0 ? null : (slash < 0 ? "" : folder.Substring(0, slash)),
            folders = Directory.GetDirectories(dir).Select(d => Path.GetFileName(d))
                .Select(n => new { name = n, folder = folder.Length == 0 ? n : folder + "/" + n }).ToArray(),
            images = Directory.GetFiles(dir)
                .Where(f => ImageExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
                .Select(f => new FileInfo(f))
                .Select(f => new { name = f.Name, folder = folder, size = f.Length, url = BuildUrl(baseUrl, folder, f.Name) }).ToArray()
        };
    }

    // Checks the first bytes, so a script renamed to .png is not stored as an image.
    public static bool LooksLikeImage(byte[] b, string ext)
    {
        switch (ext)
        {
            case ".jpg": case ".jpeg": case ".jfif": return b.Length > 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF;
            case ".png": return b.Length > 8 && b[0] == 0x89 && At(b, 1, "PNG");
            case ".gif": return At(b, 0, "GIF8");
            case ".webp": return At(b, 0, "RIFF") && At(b, 8, "WEBP");
            case ".bmp": return At(b, 0, "BM");
            case ".avif": return At(b, 4, "ftyp");
            default: return false;
        }
    }

    static bool At(byte[] b, int offset, string ascii)
    {
        return b.Length >= offset + ascii.Length && Encoding.ASCII.GetString(b, offset, ascii.Length) == ascii;
    }
}

Web Forms: save this as RTEUpload.ashxin the site root, replacing the sample’s.

<%@ WebHandler Language="C#" Class="RTEUpload" %>

using System;
using System.IO;
using System.Linq;
using System.Web;

// Upload handler for RichTextEditor images (rte-upload.js and the Insert Gallery "Upload" button).
// The editor posts the raw file as the request body, with name, type, size and an optional folder
// in the query string. Replies "READY:<url>" (200) or "ERROR:<message>" (500).
public class RTEUpload : IHttpHandler
{
    const int MaxBytes = 4000000;
    const string RootVirtual = "~/imageuploads";

    public void ProcessRequest(HttpContext context)
    {
        // TODO: check that the current user is allowed to upload.
        context.Response.ContentType = "text/plain";

        string ext = Path.GetExtension(context.Request.QueryString["name"] ?? "").ToLowerInvariant();
        if (!RteImageFolder.ImageExtensions.Contains(ext)) { Fail(context, "invalid file extension"); return; }
        if (context.Request.ContentLength <= 0 || context.Request.ContentLength > MaxBytes) { Fail(context, "file too big"); return; }

        string root = Path.GetFullPath(context.Server.MapPath(RootVirtual));
        Directory.CreateDirectory(root);
        string folder = RteImageFolder.Normalize(context.Request.QueryString["folder"]);
        string dir = RteImageFolder.Resolve(root, folder);
        if (dir == null) { Fail(context, "invalid folder"); return; }

        byte[] data;
        using (var memory = new MemoryStream())
        {
            context.Request.InputStream.CopyTo(memory);
            data = memory.ToArray();
        }
        if (!RteImageFolder.LooksLikeImage(data, ext)) { Fail(context, "file contents do not match its type"); return; }

        // Never reuse the uploaded file name: it is chosen by whoever sends the request.
        string fileName = DateTime.UtcNow.ToString("yyyyMMddHHmmss") + "-" + Guid.NewGuid().ToString("N") + ext;
        File.WriteAllBytes(Path.Combine(dir, fileName), data);

        context.Response.Write("READY:" + RteImageFolder.BuildUrl(VirtualPathUtility.ToAbsolute(RootVirtual), folder, fileName));
    }

    static void Fail(HttpContext context, string message)
    {
        context.Response.StatusCode = 500;
        // Without this, IIS replaces the body with its own error page and the editor
        // shows a generic failure instead of the message.
        context.Response.TrySkipIisCustomErrors = true;
        context.Response.Write("ERROR:" + message);
    }

    public bool IsReusable { get { return false; } }
}

MVC 5: the same logic as a controller, which also holds the gallery actions from step 3. With the default route its URLs are /RTE/Upload and /RTE/Gallery.

using System;
using System.IO;
using System.Linq;
using System.Web.Mvc;

// ASP.NET MVC 5 endpoints for RichTextEditor image uploads and the Insert Gallery dialog.
//   POST /RTE/Upload?name=&type=&size=&folder=       raw file body -> READY:<url> / ERROR:<message>
//   GET  /RTE/Gallery?folder=a/b                     folder listing (JSON)
//   POST /RTE/Gallery  (action=create-folder, folder, name)
public class RTEController : Controller
{
    const int MaxBytes = 4000000;
    const string RootVirtual = "~/imageuploads";

    string Root()
    {
        string root = Path.GetFullPath(Server.MapPath(RootVirtual));
        Directory.CreateDirectory(root);
        return root;
    }

    [HttpPost]
    public ActionResult Upload(string name, string folder)
    {
        // TODO: check that the current user is allowed to upload.
        string ext = Path.GetExtension(name ?? "").ToLowerInvariant();
        if (!RteImageFolder.ImageExtensions.Contains(ext)) return UploadError("invalid file extension");
        if (Request.ContentLength <= 0 || Request.ContentLength > MaxBytes) return UploadError("file too big");

        string relative = RteImageFolder.Normalize(folder);
        string dir = RteImageFolder.Resolve(Root(), relative);
        if (dir == null) return UploadError("invalid folder");

        byte[] data;
        using (var memory = new MemoryStream())
        {
            Request.InputStream.CopyTo(memory);
            data = memory.ToArray();
        }
        if (!RteImageFolder.LooksLikeImage(data, ext)) return UploadError("file contents do not match its type");

        string fileName = DateTime.UtcNow.ToString("yyyyMMddHHmmss") + "-" + Guid.NewGuid().ToString("N") + ext;
        System.IO.File.WriteAllBytes(Path.Combine(dir, fileName), data);
        return Content("READY:" + RteImageFolder.BuildUrl(Url.Content(RootVirtual), relative, fileName), "text/plain");
    }

    [HttpGet]
    public ActionResult Gallery(string folder)
    {
        // TODO: check that the current user is allowed to browse images.
        return Json(RteImageFolder.BuildPayload(Root(), Url.Content(RootVirtual), RteImageFolder.Normalize(folder)), JsonRequestBehavior.AllowGet);
    }

    [HttpPost, ActionName("Gallery")]
    public ActionResult CreateFolder()
    {
        // Read "action" from the form: as an action parameter, MVC would bind it to the route value.
        if (Request.Form["action"] != "create-folder") return GalleryError("unsupported action");
        string root = Root();
        string parent = RteImageFolder.Normalize(Request.Form["folder"]);
        string name = (Request.Form["name"] ?? "").Trim();
        if (RteImageFolder.Resolve(root, parent) == null || !RteImageFolder.IsValidName(name)) return GalleryError("invalid folder name");

        string relative = parent.Length == 0 ? name : parent + "/" + name;
        Directory.CreateDirectory(Path.Combine(root, relative.Replace('/', Path.DirectorySeparatorChar)));
        return Json(RteImageFolder.BuildPayload(root, Url.Content(RootVirtual), relative));
    }

    ActionResult UploadError(string message)
    {
        Response.StatusCode = 500;
        Response.TrySkipIisCustomErrors = true;
        return Content("ERROR:" + message, "text/plain");
    }

    ActionResult GalleryError(string message)
    {
        Response.StatusCode = 400;
        Response.TrySkipIisCustomErrors = true;
        return Json(new { error = message });
    }
}

What the handler does on purpose:

Each handler has a TODO where you check who is signed in. Skip it and anyone on the internet can store files on your server.

Register the handler on the page

Include the sample’s rte-upload.js after the editor scripts, and set the handler URL on its first line: var uploadhandlerpath = "/RTEUpload.ashx"; for Web Forms, or "/RTE/Upload" for MVC.

<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 = "/RTEGallery.ashx";   // MVC: "/RTE/Gallery" (step 3b)
  var editor1 = new RichTextEditor("#div_editor1", config);
</script>

rte-upload.js defines a global function, rte_file_upload_handler. Keep it global. You can also set config.file_upload_handlerto a function for a single editor, but the gallery’s Upload button only looks for the global function: set only the per-editor option and the gallery shows no Upload button.

2. Where images are saved, and which URL is stored

The handler decides two things: the folder on disk, and the URL it sends back.

The sample rte-upload.js hard-codes /RTEUpload.ashx, so under a virtual directory change it to include the directory, for example /intranet/RTEUpload.ashx.

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. The default leaves URLs as they are.

Size limits. The handler refuses files over 4,000,000 bytes. ASP.NET’s own limit, maxRequestLength, defaults to 4,096 KB, just above that. To allow bigger images, raise MaxBytes in the handler and both limits in web.config:

<configuration>
  <system.web>
    <httpRuntime maxRequestLength="10240" />               <!-- kilobytes -->
  </system.web>
  <system.webServer>
    <security>
      <requestFiltering>
        <requestLimits maxAllowedContentLength="10485760" /> <!-- bytes -->
      </requestFiltering>
    </security>
  </system.webServer>
</configuration>

With no endpoint configured, the gallery 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. In this mode, images uploaded from the gallery are added to the list for the current page only.

Set config.galleryEndpoint and the dialog becomes a folder browser. The endpoint answers two requests:

Both return this JSON. parentFolder is null at the top level, which disables Up, and "" for a folder directly under it:

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

If the endpoint fails, the dialog reports the error and falls back to galleryImages.

Web Forms: RTEGallery.ashx, using the helper class from step 1.

<%@ WebHandler Language="C#" Class="RTEGallery" %>

using System.IO;
using System.Web;
using System.Web.Script.Serialization;

// Gallery endpoint for RichTextEditor's Insert Gallery dialog (config.galleryEndpoint).
//   GET  RTEGallery.ashx?folder=a/b                        list that folder
//   POST action=create-folder, folder=a/b, name=New         create a sub-folder, then list it
public class RTEGallery : IHttpHandler
{
    const string RootVirtual = "~/imageuploads";

    public void ProcessRequest(HttpContext context)
    {
        // TODO: check that the current user is allowed to browse images and create folders.
        context.Response.ContentType = "application/json";
        string root = Path.GetFullPath(context.Server.MapPath(RootVirtual));
        Directory.CreateDirectory(root);

        string folder;
        if (context.Request.HttpMethod == "POST")
        {
            if (context.Request.Form["action"] != "create-folder") { Fail(context, "unsupported action"); return; }
            string parent = RteImageFolder.Normalize(context.Request.Form["folder"]);
            string name = (context.Request.Form["name"] ?? "").Trim();
            if (RteImageFolder.Resolve(root, parent) == null || !RteImageFolder.IsValidName(name)) { Fail(context, "invalid folder name"); return; }
            folder = parent.Length == 0 ? name : parent + "/" + name;
            Directory.CreateDirectory(Path.Combine(root, folder.Replace('/', Path.DirectorySeparatorChar)));
        }
        else
        {
            folder = RteImageFolder.Normalize(context.Request.QueryString["folder"]);
        }

        string baseUrl = VirtualPathUtility.ToAbsolute(RootVirtual);
        context.Response.Write(new JavaScriptSerializer().Serialize(RteImageFolder.BuildPayload(root, baseUrl, folder)));
    }

    static void Fail(HttpContext context, string message)
    {
        context.Response.StatusCode = 400;
        context.Response.TrySkipIisCustomErrors = true;
        context.Response.Write(new JavaScriptSerializer().Serialize(new { error = message }));
    }

    public bool IsReusable { get { return false; } }
}

MVC: the Gallery and CreateFolder actions in the controller above. CreateFolder reads action from Request.Form on purpose: as an action parameter, MVC would fill it with the route value "Gallery".

Then point the editor at it with config.galleryEndpoint = "/RTEGallery.ashx"; in Web Forms or "/RTE/Gallery" in MVC, as in the page above.

Uploading into the folder being browsed

The gallery’s Upload button normally uses rte_file_upload_handler, so every file lands in the top-level folder. To upload into the folder the user is looking at, also define window.richTextBoxUploadFile. The gallery prefers it and passes the current folder, which the handler above accepts as 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", "/RTEUpload.ashx?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);
};
// MVC: use "/RTE/Upload" instead of "/RTEUpload.ashx".

Keep rte-upload.js too: the image dialog, paste and drag-and-drop still use it.

Checklist

The C# on this page was compiled with the .NET Framework C# 5 compiler against System.Weband ASP.NET MVC 5.2.7. The helper’s folder resolution, path-traversal rejection, URL escaping, content check and gallery JSON were run as tests; the handlers were not run under IIS.

Related: the ASP.NET Core 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.