画像ギャラリーのパスを設定する
「Insert Gallery」ダイアログは、すでにサーバーにある画像を表示するので、書き手は同じ画像をもう一度アップロードせずに再利用できます。ダイアログが画像を取得する方法は 2 つあります。設定に書いた固定の一覧か、ユーザーがフォルダーを移動するたびにダイアログが呼び出す、自分で用意したエンドポイントです。
固定の一覧
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を、その 1 階層下では空文字列を使ってください。空文字列は親がないことではなく、ルートを意味します。urlは、文書に残る唯一のフィールドです。読者のブラウザーがリクエストするものなので、管理画面にサインインしていない訪問者でも機能し、ページを保存して別の場所で開き直した後も機能しなければなりません。sizeはバイト単位で、画像の横に表示されます。すべてのファイルの情報を取得したくない場合は省略してください。
リクエストが失敗すると、ダイアログは 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 に変換する
ここは正しく作る価値のある部分であり、2 つの側が合意していなければならない部分です。アップロードハンドラーはファイルを書き込む場所を決め、ギャラリーはそれを読み取る URL を決めます。3 つのルールで両者を一致させます。
- 物理パスと URL のプレフィックスを持つ、1 つのルートを保つ。返す URL はすべて、プレフィックスとフォルダーとファイル名をつなげたものです。プレフィックスは実行時に求めてください(ASP.NET では
VirtualPathUtility.ToAbsolute("~/imageuploads"))。そうすれば、仮想ディレクトリの下にデプロイしてもギャラリーは機能します。 - 各セグメントを、セグメントだけをエスケープする。そうしないと、Site photos というフォルダーや roof #2.jpg というファイルで URL が壊れます。各部分をエスケープしてから
/でつないでください。パス全体をエスケープすると、区切り文字までエスケープされてしまいます。 - 使う前にフォルダーを解決し、ルートの中にあることを確認する。
folderの値はブラウザーから届くので、悪意のあるものとして扱ってください。空、.、..、またはファイル名に使えない文字を含むセグメントは拒否し、そのうえで完全なパスを解決して、まだルートで始まっていることを確認します。この最後のチェックがないと、?folder=../../App_Dataで、公開するつもりのなかったディレクトリの一覧が表示されてしまいます。
これらはアップロードハンドラーにも当てはまります。同じ folder の値を受け取り、同じ方法で解決しなければなりません。対応付けを 2 回書くのではなく、1 つのヘルパーを両者で共有してください。
ASP.NET Core
2 つのエンドポイントと、それらがアップロードのエンドポイントと共有する 4 つのヘルパーです。アップロードフォルダーは 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
正規化、解決、URL の組み立て、ペイロードの組み立てのメソッドを持つ共有の RteImageFolder ヘルパーを使う、ジェネリックハンドラーです。MVC では、同じ 2 つのアクションをコントローラー(/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 を解決し、ギャラリーの URL を組み立てるのと同じコードで組み立てた URL を READY: に続けて返さなければなりません。両者が一致しないと、アップロードしたばかりの画像が文書には表示されるのに、その横のギャラリーには表示されなくなります。
ユーザーに公開する前に
- 両方のエンドポイントで、サインインしたユーザーを必須にする。ギャラリーはファイル名を一覧表示し、ディレクトリを作成します。匿名のままにすると、誰でも読めるアップロードフォルダーのディレクトリ一覧となり、誰でもそこにフォルダーを作成できてしまいます。
- 文書を共有しない場合は、ルートをユーザーごとまたはテナントごとに分ける。顧客やユーザーはリクエストのパラメーターではなく、必ずセッションから取得し、閲覧できるパスの一部ではなく、物理的なルートの一部にしてください。
- 画像だけを返す。一覧を作るときは拡張子で絞り込み、アップロードのエンドポイントでは拡張子だけでなくファイルの先頭バイトもチェックして、
.pngに名前を変えたスクリプトが保存されないようにしてください。アップロードフォルダーは、X-Content-Type-Options: nosniffを付け、スクリプトを実行させずに配信してください。 - ブラウザーが送信した名前を決して再利用しない。保存するファイル名は生成し、元の名前は必要な場合にキャプションとしてだけ残してください。
ASP.NET のコントロールから移行する場合
以前のサーバーコントロールでは、これらのパスは SetSecurity と .config のポリシーファイルから取得しており、そこには動画、文書、テンプレート用の別々のギャラリーもありました。JavaScript のエディターにはポリシーファイルも、それを読み取るサーバー側の部分もありません。その設定は現在、上記のエンドポイントにあり、閲覧できるのは画像ギャラリーだけです。動画と文書は、URL で、またはアップロードハンドラーを通じて挿入します。
関連情報
- ファイルのアップロード — アップロードハンドラー自体、サイズ制限、アップロードが失敗したときの動作。
- ASP.NET Web Forms と MVC での画像アップロードとギャラリー、ASP.NET Core 版、PHP 版 — これらの抜粋の元になった完全なプロジェクト。
- 設定リファレンス — ギャラリーとアップロードのすべてのプロパティを 1 つの表にまとめています。