设置图片库路径
Insert Gallery 对话框会显示服务器上已有的图片,这样作者可以重复使用某张图片,而不必再次上传。它通过两种方式之一获取图片:一种是您写在配置中的固定列表,另一种是您自己的一个端点,对话框会在用户逐个浏览文件夹时调用它。
固定列表
galleryImages 接受一个普通数组。每个条目可以是一个 URL、一个由 URL、名称、说明文字组成的数组,或者一个对象。不涉及任何服务器代码,对话框也不显示文件夹。
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);默认情况下,这个属性指向我们自己网站上的几张演示图片。请替换它:不应该向您的用户提供我们的图片,对话框也不应该依赖另一个域名是否可以访问。
可浏览的图片库
把 galleryEndpoint 设置为您自己的一个 URL。对话框在打开时请求它,之后每当用户打开一个文件夹时再次请求,并渲染该 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);端点返回什么
带有 folder 参数的 GET 请求会返回该文件夹的子文件夹和图片:
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"
}
]
}currentFolder是所请求的路径,采用您的端点所理解的形式。它对编辑器来说是不透明的:对话框只会把它原样发回给您。currentFolderDisplay是用户在对话框中看到的内容,因此可以是一个友好的名称,而不是路径。parentFolder决定“向上”条目。在根目录下使用null,在根目录下一级使用空字符串——空字符串表示根目录,而不是没有父级。url是文档唯一保留的字段。读者的浏览器会请求它,因此对于没有登录到您管理后台的访问者,它也必须可用;页面保存后在其他地方重新打开时,它也必须仍然可用。size以字节为单位,显示在图片旁边;如果不想对每个文件都调用 stat,可以省略它。
如果请求失败,对话框会退回到 galleryImages,并且不显示文件夹——因此端点 URL 中的拼写错误看起来只是一个短得奇怪的图片库,而不是一个错误。如果您看到了演示图片,请检查浏览器的网络选项卡。
New Folder 按钮会向同一个 URL POST 一个表单:
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把文件夹转换为 URL
这一部分值得做对,也是两部分必须达成一致的地方:上传处理程序决定文件写入到哪里,而图片库决定从哪个 URL 读取它。有三条规则能让两者保持一致。
- 只保留一个根目录,带有物理路径和 URL 前缀。您返回的每个 URL 都是前缀加上文件夹再加上文件名。请在运行时推导前缀——在 ASP.NET 中使用
VirtualPathUtility.ToAbsolute("~/imageuploads")——这样即使部署在虚拟目录下,图片库也能正常工作。 - 对每一段进行转义,并且只转义各段。否则,名为 Site photos 的文件夹或名为 roof #2.jpg 的文件会生成损坏的 URL。先转义各个部分,再用
/把它们连接起来——转义整个路径会把分隔符也一并转义。 - 使用文件夹之前,先解析它并检查它是否位于根目录之内。
folder的值来自浏览器,因此要把它当作恶意输入:拒绝任何为空、为.或..、或包含文件名中无效字符的段,然后解析完整路径,并确认它仍以根目录开头。没有最后这项检查,?folder=../../App_Data就会列出一个您从未打算公开的目录。
这些规则对上传处理程序同样适用:它接收相同的 folder 值,必须以同样的方式解析。请在两者之间共用一个辅助函数,而不是把映射逻辑写两遍。
ASP.NET Core
两个端点,以及它们与上传端点共用的四个辅助方法。上传文件夹位于 wwwroot 下,因此由 UseStaticFiles 提供图片,显示图片时不会运行任何代码。
// 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 和 MVC
一个一般处理程序,使用一个共享的 RteImageFolder 辅助类,其中包含规范化、解析、构造 URL 和构造载荷的方法。对于 MVC,请把同样的两个操作方法放到一个控制器上——/RTE/Gallery——辅助类保持不变。
// 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
同样的约定。由 realpath 进行解析,而 strpos 检查确保精心构造的 folder 不会离开上传根目录。
<?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,
));上传到正在浏览的文件夹
图片库有自己的 Upload 按钮,它不使用 config.file_upload_handler。它会先查找 window.richTextBoxUploadFile,找不到时退回到 window.rte_file_upload_handler。只有前者会被告知当前打开的是哪个文件夹,因此如果希望上传的文件落在用户正在查看的位置,请定义它。
// 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);
};您的上传端点必须以与图片库完全相同的方式解析 folder,并返回 READY: 加上由构造图片库 URL 的同一段代码构造的 URL。如果两者不一致,新上传的图片会出现在文档中,却不会出现在旁边的图片库中。
在提供给用户之前
- 两个端点都要求用户已登录。图片库会列出文件名并创建目录。如果允许匿名访问,它就是一个任何人都能读取的上传文件夹目录列表,也是一个任何人都能在其中创建文件夹的途径。
- 在文档不共享的场景下,按用户或按租户划分根目录。客户或用户要从会话中获取,绝不能从请求参数中获取,并且要把它作为物理根目录的一部分,而不是可浏览路径的一部分。
- 只返回图片。列出文件时按扩展名过滤,并让上传端点同时检查文件开头的字节和扩展名,这样改名为
.png的脚本就永远不会被保存。提供上传文件夹时使用X-Content-Type-Options: nosniff,并且不允许执行脚本。 - 绝不重用浏览器发送的文件名。由服务器生成保存的文件名;如果需要,只把原始文件名保留为说明文字。
从 ASP.NET 控件迁移过来?
在旧的服务器控件中,这些路径来自 SetSecurity 和 .config 策略文件,其中还为视频、文档和模板分别配置了图片库。JavaScript 编辑器既没有策略文件,也没有读取它们的服务器端部分:上面的端点就是这些配置现在所在的地方,而图片库是唯一可浏览的库——视频和文档通过 URL 或上传处理程序插入。
延伸阅读
- 上传文件——上传处理程序本身、大小限制,以及上传失败时会发生什么。
- ASP.NET Web Forms 和 MVC 中的图片上传与图片库、ASP.NET Core 中的以及 PHP 中的——这些代码摘录所取自的完整项目。
- 配置参考——所有图片库和上传属性都在一张表中。