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

「Insert Gallery」ダイアログ。MVC 5 のサンプルの blog、products、team フォルダーと 4 つの画像が表示され、1 つが選択されている

RichTextEditor の Insert Gallery ダイアログは、サーバー上の画像を一覧表示し、フォルダーを開き、開いているフォルダーにアップロードします。ASP.NET MVC 5 でこれを使うには、GET /rte/gallery?folder=... に対して JSON を返すコントローラーのアクションが 1 つ必要です。ここで MVC の 2 つの既定の動作が障害になります。Json() は GET リクエストを拒否し、要求の検証は変わったフォルダー名を HTML の 500 ページに変えてしまいます。このページでは、その両方に対処したアクションを紹介します。

ギャラリーを有効にする

Razor · Views/Home/Index.cshtml
<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 で設定しています。

アクション

C# · Controllers/RteController.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 のファイルを名前順に並べて返します。

C# · RteFiles.cs
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 か所で組み立てています。

C# · Controllers/RteController.cs
// 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 は、ユーザーごとのフォルダーを追加したときに重要になります。
MVC 5 のサンプルで GET /rte/gallery?folder=blog が返した JSON。currentFolder、parentFolder、folders と 2 つの画像がある
blog フォルダーについてダイアログが受け取るもの。

[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 フォルダーを作成してから、以下を実行します。

Shell
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

次に読む