ASP.NET MVC 5 でリッチテキストエディターに画像ギャラリーを追加する

RichTextEditor の Insert Gallery ダイアログは、サーバー上の画像を一覧表示し、フォルダーを開き、開いているフォルダーにアップロードします。ASP.NET MVC 5 でこれを使うには、GET /rte/gallery?folder=... に対して JSON を返すコントローラーのアクションが 1 つ必要です。ここで MVC の 2 つの既定の動作が障害になります。Json() は GET リクエストを拒否し、要求の検証は変わったフォルダー名を HTML の 500 ページに変えてしまいます。このページでは、その両方に対処したアクションを紹介します。
ギャラリーを有効にする
<script>
var editor = new RichTextEditor("#editor", {
toolbar: "full",
galleryEndpoint: "@Url.Content("~/rte/gallery")",
maxUploadFileSize: 4000000 // same limit as the server
});
</script>galleryEndpoint は、ダイアログが問い合わせる URL です。maxUploadFileSize にはサーバーと同じ 4,000,000 バイトの制限を指定しているので、ダイアログは大きすぎるファイルを送信前に拒否します。rte/gallery というルートは RteController の Gallery アクションに送られます。このルートは、MVC 5 での画像アップロードで紹介したアップロード用のルートと並べて、Global.asax.cs で設定しています。
アクション
[HttpGet]
public ActionResult Gallery(string folder)
{
try
{
return JsonStatus(200, RteFiles.Listing(HttpContext, folder));
}
catch (RteException ex)
{
return JsonStatus(ex.Status, new { error = ex.Message });
}
}RteFiles.Listing は、uploads/ 内で仮想フォルダーを解決します。サブフォルダーと、.jpg、.jpeg、.png、.gif、.webp のファイルを名前順に並べて返します。
public static object Listing(HttpContextBase context, string folder)
{
folder = folder ?? "";
StorageRoot root = GetRoot(context);
string dir = ResolveFolder(root, folder);
List<string> folderNames = new List<string>();
foreach (string path in Directory.GetDirectories(dir))
{
string name = Path.GetFileName(path);
// Skip anything the dialog could not open again (hidden, odd names).
if (IsValidName(name) && (File.GetAttributes(path) & FileAttributes.Hidden) == 0)
folderNames.Add(name);
}
folderNames.Sort(CompareNames);
List<FileInfo> files = new List<FileInfo>();
foreach (FileInfo file in new DirectoryInfo(dir).GetFiles())
{
if (Array.IndexOf(ImageExtensions, file.Extension.ToLowerInvariant()) >= 0
&& (file.Attributes & FileAttributes.Hidden) == 0)
files.Add(file);
}
files.Sort(delegate (FileInfo a, FileInfo b) { return CompareNames(a.Name, b.Name); });
List<object> folders = new List<object>();
foreach (string name in folderNames)
folders.Add(new { folder = Combine(folder, name), name = name });
List<object> images = new List<object>();
foreach (FileInfo file in files)
images.Add(new { url = UrlFor(root, folder, file.Name), name = file.Name, folder = folder, size = file.Length });
string parent = null;
if (folder.Length > 0)
{
int slash = folder.LastIndexOf('/');
parent = slash < 0 ? "" : folder.Substring(0, slash);
}
return new
{
currentFolder = folder,
currentFolderDisplay = "/" + folder,
parentFolder = parent,
folders = folders,
images = images
};
}Json() と GET
Json(data) の既定は JsonRequestBehavior.DenyGet です。使い捨てのアクションで試したところ、GET では HTTP 500 と次のメッセージが返りました。「This request has been blocked because sensitive information could be disclosed to third party web sites when this is used in a GET request. To allow GET requests, set JsonRequestBehavior to AllowGet.」ギャラリーは GET でなければならないので、コントローラーでは JsonResult を 1 か所で組み立てています。
// Json() refuses GET unless told otherwise (JsonRequestBehavior.DenyGet is the default)
// and caps output at 2,097,152 characters; both are lifted here. Property names are
// written as declared, so the anonymous objects in RteFiles produce camelCase JSON.
JsonResult JsonStatus(int status, object data)
{
Response.StatusCode = status;
// Without this, IIS replaces 4xx bodies with its own error page for remote users.
Response.TrySkipIisCustomErrors = true;
// Forms authentication would turn a 401 into a 302 to the login page; keep the 401.
Response.SuppressFormsAuthenticationRedirect = true;
Response.Cache.SetCacheability(HttpCacheability.NoCache);
Response.Cache.SetNoStore();
return new JsonResult
{
Data = data,
JsonRequestBehavior = JsonRequestBehavior.AllowGet,
MaxJsonLength = int.MaxValue
};
}AllowGet:MVC のメッセージが示しているリスクは、ほかのサイトが GET のレスポンスを読み取ることです。ここでのレスポンスは常にオブジェクトで、最上位が配列になることはありません。また、一覧に含まれるのは、どのみちダイアログが表示するものだけです。その一覧を非公開にしたい場合は、後述のユーザーごとのフォルダーのように、ルートをサインインの背後に置いてください。MaxJsonLength:JsonResultはJavaScriptSerializerを使います。MVC のドキュメントによると、null の値はシリアライザーの既定値である 2,097,152 文字を意味し、数千枚の画像があるフォルダーではこれを超える可能性があります。この制限は計測していません。JavaScriptSerializerは、名前を宣言どおりに書き出します。RteFilesの匿名オブジェクトはキャメルケースを使っているので、設定なしでダイアログが想定する JSON になります。TrySkipIisCustomErrorsは、リモートの訪問者に対して(errorMode="Custom"の場合に)IIS が 4xx の本文を独自のエラーページに置き換えるのを防ぎます。SuppressFormsAuthenticationRedirectは、ユーザーごとのフォルダーを追加したときに重要になります。

[ValidateInput(false)] と folder の値
MVC は、string folder をバインドする際に ASP.NET の要求の検証を実行します。テストのためにコントローラーから [ValidateInput(false)] を削除したところ、GET /rte/gallery?folder=<b> はアクションの実行前に HTML の 500(「A potentially dangerous Request.QueryString value was detected from the client」)を返しました。この属性があれば、値は RteFiles に届き、そこでは単純なフォルダー名しか受け付けないため、レスポンスは 400 {"error":"Invalid folder."} になります。リクエストの値はどれもそれらのルールを通過しなければ使われないので、ここで検証を無効にしても安全です。
画像の URL
各画像の url は VirtualPathUtility.ToAbsolute で組み立てた /uploads/<folder>/<file> で、IIS がそれを静的ファイルとして配信します。MVC のルーティングが邪魔をすることはありません。RouteExistingFiles は既定で false なので、/uploads/、/rte-files.js、エディターの /richtexteditor/ フォルダーにある既存のファイルはルートを経由しません。フォルダー名にはスペースや ASCII 以外の文字を含めることができ、パスの各セグメントはパーセントエンコードされます。IIS Express には .webp の MIME タイプがないため、サンプルの web.config でそれを追加しています。これがないと、.webp の画像は一覧には表示されますが、その URL は 404.3 になります。
テストする
このガイドのサンプルプロジェクトを run.ps1 で起動します(ポート 5402)。ダイアログの New folder ボタンで team フォルダーを作成してから、以下を実行します。
curl "http://localhost:5402/rte/gallery?folder=team"
{"currentFolder":"team","currentFolderDisplay":"/team","parentFolder":"","folders":[],"images":[]}
curl "http://localhost:5402/rte/gallery?folder=..%2F"
{"error":"Invalid folder."}HTTP 200、続いて HTTP 400 になります。ブラウザーでは、Insert Gallery に uploads/ 内のフォルダーと画像が表示されます。
ほかのプラットフォーム向けの同じガイド
JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · Web Forms