← 全部文档

设置图片库路径

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 或上传处理程序插入。

延伸阅读