Configurar la ruta de la galería de imágenes
El cuadro de diálogo Insert Gallery muestra las imágenes que ya están en tu servidor, para que quien escribe pueda reutilizar una en lugar de volver a subirla. Las obtiene de una de dos formas: una lista fija que pones en la configuración, o un endpoint tuyo al que llama a medida que el usuario navega de carpeta en carpeta.
Una lista fija
galleryImages recibe un array simple. Cada entrada puede ser una URL, un array de URL, nombre, pie o un objeto. No interviene código de servidor, y el cuadro de diálogo no muestra carpetas.
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",
alt: "Timber stair core, looking up" // optional; core 2.6.5+ writes it as the alt text
}
];
var editor = new RichTextEditor("#div_editor", config);De serie, esta propiedad apunta a unas cuantas imágenes de demostración de nuestro propio sitio. Sustitúyela: a tus usuarios no se les deberían ofrecer las nuestras, y el cuadro de diálogo no debería depender de que otro dominio esté accesible.
Una galería navegable
Establece galleryEndpoint en una URL tuya. El cuadro de diálogo la solicita al abrirse y de nuevo cada vez que el usuario abre una carpeta, y muestra lo que devuelva esa URL.
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);Qué devuelve el endpoint
Un GET con un parámetro folder devuelve las subcarpetas y las imágenes de esa carpeta:
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",
"alt": "Roof with new solar panels"
}
]
}currentFolderes la ruta que se te pidió, tal como la entiende tu endpoint. Es opaca para el editor: el cuadro de diálogo solo te la devuelve.currentFolderDisplayes lo que ve el usuario en el cuadro de diálogo, así que puede ser un nombre legible en lugar de una ruta.parentFoldercontrola la entrada “subir”. Usanullen la raíz y una cadena vacía un nivel por debajo: una cadena vacía significa la raíz, no sin carpeta superior.urles el único campo que conserva el documento. Es lo que solicitará el navegador del lector, así que debe funcionar para un visitante que no haya iniciado sesión en tu área de administración, y debe seguir funcionando después de guardar la página y volver a abrirla en otro lugar.sizeestá en bytes y se muestra junto a la imagen; omítelo si no quieres consultar el tamaño de cada archivo.
Si la solicitud falla, el cuadro de diálogo recurre a galleryImages y no muestra carpetas, así que una errata en la URL del endpoint parece una galería extrañamente corta y no un error. Revisa la pestaña de red del navegador si ves las imágenes de demostración.
El botón New Folder envía un formulario a la misma 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 createdConvertir una carpeta en una URL
Esta es la parte que merece la pena hacer bien, y en la que las dos mitades tienen que estar de acuerdo: el controlador de carga decide dónde se escribe un archivo y la galería decide desde qué URL se lee. Tres reglas las mantienen sincronizadas.
- Mantén una sola raíz, con una ruta física y un prefijo de URL. Cada URL que devuelves es el prefijo más la carpeta más el nombre del archivo. Obtén el prefijo en tiempo de ejecución (en ASP.NET,
VirtualPathUtility.ToAbsolute("~/imageuploads")) para que la galería siga funcionando cuando se despliega bajo un directorio virtual. - Escapa cada segmento, y solo los segmentos. De lo contrario, una carpeta llamada Site photos o un archivo llamado roof #2.jpg producen una URL rota. Escapa las partes y luego únelas con
/: escapar la ruta completa escaparía también los separadores. - Resuelve la carpeta y comprueba que está dentro de la raíz antes de usarla. El valor de
folderllega del navegador, así que trátalo como hostil: rechaza cualquier segmento vacío,.,..o que contenga un carácter no válido en un nombre de archivo, y luego resuelve la ruta completa y confirma que sigue empezando por la raíz. Sin esa última comprobación,?folder=../../App_Dataenumera un directorio que nunca quisiste publicar.
Estas reglas también se aplican al controlador de carga: recibe el mismo valor de folder y debe resolverlo de la misma manera. Comparte una única función auxiliar entre ambos en lugar de escribir la correspondencia dos veces.
ASP.NET Core
Dos endpoints y las cuatro funciones auxiliares que comparten con el endpoint de carga. La carpeta de cargas está dentro de wwwroot, así que UseStaticFiles sirve las imágenes y no se ejecuta ningún código cuando se muestra una.
// 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 y MVC
Un generic handler que usa una clase auxiliar compartida, RteImageFolder, con los métodos de normalizar, resolver, construir la URL y construir la carga útil. Para MVC, pon las mismas dos acciones en un controlador (/RTE/Gallery) y deja la clase auxiliar tal como está.
// 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
El mismo contrato. realpath se encarga de resolver, y la comprobación con strpos es lo que mantiene un folder manipulado dentro de la raíz de cargas.
<?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,
));Subir archivos a la carpeta que se está explorando
La galería tiene su propio botón Upload, y no usa config.file_upload_handler. Busca primero window.richTextBoxUploadFile y, si no existe, recurre a window.rte_file_upload_handler. Solo a la primera forma se le indica qué carpeta está abierta, así que defínela si quieres que las cargas vayan a donde está mirando el usuario.
// 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);
};Tu endpoint de carga debe resolver folder exactamente como lo hace la galería y devolver READY: seguido de la URL construida con el mismo código que construye las URL de la galería. Si los dos no coinciden, una imagen recién subida aparece en el documento, pero no en la galería que tiene al lado.
Antes de ponerlo delante de los usuarios
- Exige un usuario con sesión iniciada en ambos endpoints. La galería enumera nombres de archivo y crea directorios. Si se deja anónima, es un listado de tu carpeta de cargas que cualquiera puede leer, y una forma de que cualquiera cree carpetas en ella.
- Limita la raíz por usuario o por inquilino cuando los documentos no se compartan. Toma el cliente o el usuario de la sesión, nunca de un parámetro de la solicitud, y haz que forme parte de la raíz física y no de la ruta navegable.
- Devuelve solo imágenes. Filtra por extensión al enumerar, y haz que el endpoint de carga compruebe los primeros bytes del archivo además de su extensión, para que nunca se guarde un script renombrado a
.png. Sirve la carpeta de cargas conX-Content-Type-Options: nosniffy sin ejecución de scripts. - Nunca reutilices el nombre que envió el navegador. Genera el nombre del archivo guardado; conserva el original solo como pie si lo necesitas.
¿Vienes del control de ASP.NET?
En el antiguo control de servidor, estas rutas venían de SetSecurity y de los archivos de políticas .config, que también incluían galerías separadas para video, documentos y plantillas. El editor de JavaScript no tiene archivos de políticas ni una mitad de servidor que los lea: el endpoint de arriba es donde vive ahora esa configuración, y la galería de imágenes es la única navegable; el video y los documentos se insertan por URL o a través del controlador de carga.
Para seguir leyendo
- Subir archivos: el propio controlador de carga, los límites de tamaño y qué ocurre cuando falla una carga.
- Carga de imágenes y galería en ASP.NET Web Forms y MVC, en ASP.NET Core y en PHP: los proyectos completos de los que se toman estos fragmentos.
- Referencia de configuración: todas las propiedades de la galería y de la carga en una sola tabla.