Set up the image gallery path

The Insert Gallery dialog shows the images already on your server, so a writer can reuse one instead of uploading it again. It gets them in one of two ways: a fixed list you put in the configuration, or an endpoint of yours that it calls as the user browses from folder to folder.

A fixed list

galleryImages takes a plain array. Each entry may be a URL, an array of URL, name, caption, or an object. No server code is involved, and the dialog shows no folders.

var config = {};

// The simplest gallery: a fixed list, no server code at all.
config.galleryImages = [
  "/imageuploads/roof.jpg",                                  // just the URL
  ["/imageuploads/atrium.jpg", "Atrium", "1600 x 900"],      // URL, name, caption
  {
    url: "/imageuploads/stair.jpg",
    thumbnail: "/imageuploads/thumbs/stair.jpg",             // optional, for a faster dialog
    name: "Stair core",
    meta: "440 KB"
  }
];

var editor = new RichTextEditor("#div_editor", config);

Out of the box this property points at a handful of demonstration pictures on our own site. Replace it: your users should not be offered ours, and the dialog should not depend on another domain being reachable.

A browsable gallery

Set galleryEndpoint to a URL of your own. The dialog requests it when it opens and again each time the user opens a folder, and it renders whatever that URL returns.

var config = {};

// Browsable: the dialog asks this URL for each folder as the user opens it.
config.galleryEndpoint = "/rte/gallery";

// Keep galleryImages as well - it is what the dialog falls back to if the
// endpoint is unreachable, so the user sees a gallery rather than an error.
config.galleryImages = ["/imageuploads/roof.jpg"];

var editor = new RichTextEditor("#div_editor", config);

What the endpoint returns

A GET with a folder parameter returns that folder's sub-folders and images:

GET /rte/gallery?folder=2026/site-photos

{
  "currentFolder": "2026/site-photos",
  "currentFolderDisplay": "/2026/site-photos",
  "parentFolder": "2026",
  "folders": [
    { "name": "week-1", "folder": "2026/site-photos/week-1" }
  ],
  "images": [
    {
      "name": "roof.jpg",
      "folder": "2026/site-photos",
      "size": 448123,
      "url": "/imageuploads/2026/site-photos/roof.jpg"
    }
  ]
}
  • currentFolder is the path you were asked for, as your endpoint understands it. It is opaque to the editor: the dialog only sends it back to you.
  • currentFolderDisplay is what the user sees in the dialog, so it can be a friendly name rather than a path.
  • parentFolder drives the “up” entry. Use nullat the root, and an empty string one level below it — an empty string means the root, not no parent.
  • urlis the only field the document keeps. It is what the reader's browser will request, so it must work for a visitor who is not signed in to your admin area, and it must still work after the page is saved and re-opened elsewhere.
  • size is in bytes and is shown next to the image; omit it if you do not want to stat every file.

If the request fails, the dialog falls back to galleryImagesand shows no folders — so a typo in the endpoint URL looks like an oddly short gallery rather than an error. Check the browser's network tab if you see the demonstration pictures.

The New Folder button posts a form to the same URL:

POST /rte/gallery
Content-Type: application/x-www-form-urlencoded

action=create-folder&folder=2026%2Fsite-photos&name=week-2

-> the same JSON as a GET, listing the folder that was just created

Turning a folder into a URL

This is the part worth getting right, and the part the two halves have to agree on: the upload handler decides where a file is written, and the gallery decides what URL it is read from. Three rules keep them in step.

  • Keep one root, with a physical path and a URL prefix.Every URL you return is the prefix plus the folder plus the file name. Derive the prefix at runtime — in ASP.NET, VirtualPathUtility.ToAbsolute("~/imageuploads") — so the gallery survives being deployed under a virtual directory.
  • Escape each segment, and only the segments. A folder called Site photos or a file called roof #2.jpg produces a broken URL otherwise. Escape the parts, then join them with /— escaping the whole path would escape the separators too.
  • Resolve the folder and check it is inside the root, before you use it. The folder value arrives from the browser, so treat it as hostile: reject any segment that is empty, ., .. or contains a character invalid in a file name, then resolve the full path and confirm it still starts with the root. Without that last check, ?folder=../../App_Data lists a directory you never meant to publish.

Both are also true of the upload handler: it takes the same folder value and must resolve it the same way. Share one helper between them rather than writing the mapping twice.

ASP.NET Core

Two endpoints, and the four helpers they share with the upload endpoint. The upload folder is under wwwroot, so UseStaticFiles serves the images and no code runs when one is displayed.

// ASP.NET Core minimal API. Add .RequireAuthorization() to both endpoints.
const string UploadUrl = "/imageuploads";               // served by UseStaticFiles
string uploadRoot = Path.GetFullPath(Path.Combine(app.Environment.WebRootPath, "imageuploads"));
Directory.CreateDirectory(uploadRoot);

app.MapGet("/rte/gallery", (string? folder) =>
{
    string relative = Normalize(folder);
    if (ResolveFolder(relative) is null) relative = "";   // fall back to the root
    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));
});

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. This is the check that stops ?folder=../../web.config.
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;
}

// folder + file name -> the URL the browser will request
string BuildUrl(string relative, string fileName) =>
    UploadUrl + "/" + string.Join("/",
        (relative.Length == 0 ? new[] { fileName } : relative.Split('/').Append(fileName))
        .Select(Uri.EscapeDataString));

object BuildPayload(string relative)
{
    string dir = ResolveFolder(relative)!;
    int slash = relative.LastIndexOf('/');
    return new
    {
        currentFolder = relative,
        currentFolderDisplay = "/" + relative,
        parentFolder = relative.Length == 0 ? null : (slash < 0 ? "" : relative.Substring(0, 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) })
    };
}

ASP.NET Web Forms and MVC

A generic handler, using a shared RteImageFolderhelper that holds the normalize, resolve, build-URL and build-payload methods. For MVC, put the same two actions on a controller — /RTE/Gallery— and keep the helper as it is.

// RTEGallery.ashx - Web Forms. Put RteImageFolder.cs in App_Code.
//   GET  RTEGallery.ashx?folder=a/b                       list that folder
//   POST action=create-folder, folder=a/b, name=New       create it, then list it
public class RTEGallery : IHttpHandler
{
    const string RootVirtual = "~/imageuploads";

    public void ProcessRequest(HttpContext context)
    {
        // TODO: check that the current user may 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"]);
        }

        // ToAbsolute turns "~/imageuploads" into the URL prefix, so the gallery
        // keeps working when the site is deployed under a virtual directory.
        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; } }
}

PHP

The same contract. realpath does the resolving, and the strpos check is what keeps a crafted folder inside the upload root.

<?php
// rte-gallery.php
//   GET  rte-gallery.php?folder=a/b                      list that folder
//   POST action=create-folder, folder=a/b, name=New      create it, then list it
header("Content-Type: application/json; charset=utf-8");

// TODO: check that the current user may browse images and create folders.

$rootDir   = __DIR__ . DIRECTORY_SEPARATOR . "imageuploads";  // same folder as rte-upload.php
$rootUrl   = "/imageuploads";                                  // the URL that folder is served at
$imageExts = array("jpg", "jpeg", "jfif", "png", "gif", "webp", "bmp", "avif");

function gallery_valid_name($part) {
    return $part !== "" && $part !== "." && $part !== ".." && strpbrk($part, "/\\:*?\"<>|") === false;
}

// The real directory for "a/b", or false if it is invalid, missing, or outside the root.
function gallery_dir($rootReal, $folder) {
    if ($folder === "") return $rootReal;
    foreach (explode("/", $folder) as $part) {
        if (!gallery_valid_name($part)) return false;
    }
    $dir = realpath($rootReal . DIRECTORY_SEPARATOR . str_replace("/", DIRECTORY_SEPARATOR, $folder));
    return ($dir !== false && is_dir($dir) && strpos($dir, $rootReal . DIRECTORY_SEPARATOR) === 0) ? $dir : false;
}

// folder + file name -> the URL the browser will request
function gallery_url($rootUrl, $folder, $name) {
    $parts = $folder === "" ? array() : explode("/", $folder);
    $parts[] = $name;
    return $rootUrl . "/" . implode("/", array_map("rawurlencode", $parts));
}

if (!is_dir($rootDir)) mkdir($rootDir, 0755, true);
$rootReal = realpath($rootDir);

if ($_SERVER["REQUEST_METHOD"] === "POST") {
    if ((isset($_POST["action"]) ? $_POST["action"] : "") !== "create-folder") {
        http_response_code(400); echo json_encode(array("error" => "unsupported action")); exit;
    }
    $parent    = trim(isset($_POST["folder"]) ? (string)$_POST["folder"] : "", "/");
    $name      = trim(isset($_POST["name"]) ? (string)$_POST["name"] : "");
    $parentDir = gallery_dir($rootReal, $parent);
    if ($parentDir === false || !gallery_valid_name($name)) {
        http_response_code(400); echo json_encode(array("error" => "invalid folder name")); exit;
    }
    $newDir = $parentDir . DIRECTORY_SEPARATOR . $name;
    if (!is_dir($newDir)) mkdir($newDir, 0755);
    $folder = $parent === "" ? $name : $parent . "/" . $name;
} else {
    $folder = trim(isset($_GET["folder"]) ? (string)$_GET["folder"] : "", "/");
}

$dir = gallery_dir($rootReal, $folder);
if ($dir === false) { $folder = ""; $dir = $rootReal; }   // fall back to the root

$folders = array();
$images  = array();
foreach (scandir($dir) as $entry) {
    if ($entry === "." || $entry === "..") continue;
    $path = $dir . DIRECTORY_SEPARATOR . $entry;
    if (is_dir($path)) {
        $folders[] = array("name" => $entry, "folder" => $folder === "" ? $entry : $folder . "/" . $entry);
    } elseif (in_array(strtolower(pathinfo($entry, PATHINFO_EXTENSION)), $imageExts, true)) {
        $images[] = array(
            "name"   => $entry,
            "folder" => $folder,
            "size"   => filesize($path),
            "url"    => gallery_url($rootUrl, $folder, $entry),
        );
    }
}

$slash = strrpos($folder, "/");
echo json_encode(array(
    "currentFolder"        => $folder,
    "currentFolderDisplay" => "/" . $folder,
    "parentFolder"         => $folder === "" ? null : ($slash === false ? "" : substr($folder, 0, $slash)),
    "folders"              => $folders,
    "images"               => $images,
));

Uploading into the folder being browsed

The gallery has an Upload button of its own, and it does not use config.file_upload_handler. It looks for window.richTextBoxUploadFile first, and falls back to window.rte_file_upload_handler. Only the first form is told which folder is open, so define it if you want uploads to land where the user is looking.

// Lets the gallery's Upload button put the file in the folder being browsed.
// Without this the gallery falls back to rte_file_upload_handler, which knows
// nothing about folders, and every upload lands in the top-level folder.
window.richTextBoxUploadFile = function (file, callback, options) {
  var folder = (options && options.folder) || "";
  var data = new FormData();
  data.append("fileforphp", file);
  var xh = new XMLHttpRequest();
  xh.open("POST", "/rte-upload.php?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(data);
};

Your upload endpoint must resolve folder exactly as the gallery does, and return READY:followed by the URL built by the same code that builds the gallery's URLs. If the two disagree, a freshly uploaded image appears in the document but not in the gallery beside it.

Before you put it in front of users

  • Require a signed-in user on both endpoints. The gallery lists file names and creates directories. Left anonymous, it is a directory listing of your upload folder that anyone can read, and a way for anyone to create folders in it.
  • Scope the root per user or per tenant where documents are not shared. Take the customer or user from the session, never from a request parameter, and make it part of the physical root rather than part of the browsable path.
  • Return images only.Filter by extension when listing, and have the upload endpoint check the file's first bytes as well as its extension, so a script renamed .png is never stored. Serve the upload folder with X-Content-Type-Options: nosniff and no script execution.
  • Never reuse the name the browser sent. Generate the stored file name; keep the original only as a caption if you need it.

Coming from the ASP.NET control?

In the old server control these paths came from SetSecurity and the .configpolicy files, which also carried separate galleries for video, documents and templates. The JavaScript editor has no policy files and no server half to read them: the endpoint above is where that configuration now lives, and the image gallery is the only browsable one — video and documents are inserted by URL or through the upload handler.

Further reading