ASP.NET Core でリッチテキストエディターから画像をアップロードする

ASP.NET Core のサンプルにアップロードされ、RichTextEditor に挿入された夜の街の写真。上部にはすべてのボタンを表示したツールバーがある

画像がドロップ、貼り付け、または選択されると、RichTextEditor はそれを multipart/form-data の POST としてサーバーに送信し、返ってきた URL を本文に挿入します。ASP.NET Core では、これは IFormFile を受け取る 1 つのアクション、または 1 つの最小 API のエンドポイントです。このページでは、その両方と、サーバーが何かを保存する前にチェックすること、そして curl で確認できるレスポンスを紹介します。

ページ

エディターは /richtexteditor/ から読み込みます。小さな共通スクリプト rte-files.js がエディターをサーバーに接続します。このスクリプトは、ドラッグ&ドロップ、貼り付け、「Insert Image」ダイアログ用のアップロードハンドラーと、ギャラリーが開いているフォルダーにアップロードするときのハンドラーを設定します。どちらも /rte/upload に POST し、レスポンスから {"url": "..."} を読み取ります。

Razor · Views/Home/Index.cshtml
<link rel="stylesheet" href="/richtexteditor/rte_theme_default.css" />
<script src="/richtexteditor/rte.js"></script>
<script src="/richtexteditor/plugins/all_plugins.js"></script>
<script src="/rte-files.js"></script>
Razor · Views/Home/Index.cshtml
var editor = new RichTextEditor("#editor", {
    toolbar: "full",
    galleryEndpoint: "/rte/gallery",
    maxUploadFileSize: 4000000 // same limit as the server, checked before the upload starts
});

maxUploadFileSize を指定すると、エディターは 4,000,000 バイトを超えるファイルを送信前に拒否します。それでも、サーバーは自分でサイズをチェックします。

コントローラーのアクション

MVC のサンプルでは、ルートは [ApiController] のアクションです。[ApiController] は、IFormFile パラメーターを自動的にフォームからバインドします。

C# · Controllers/RteController.cs
// POST /rte/upload   file, folder
[HttpPost("upload")]
[RequestSizeLimit(RteFiles.MaxRequestBytes)]                              // the whole request body
[RequestFormLimits(MultipartBodyLengthLimit = RteFiles.MaxRequestBytes)]  // the multipart parser
public async Task<IActionResult> Upload(IFormFile? file, [FromForm] string? folder) =>
    Send(await RteFiles.Save(HttpContext, file, folder));

パラメーターを IFormFile? にしているのは意図的です。null 許容参照型が有効な場合、null 非許容のパラメーターは必須として扱われ、ファイルのないリクエストには、エディターが表示する {"error": "..."} ではなく、[ApiController] の ProblemDetails が返ってしまいます。2 つの属性は、このアクションに限ってリクエストの制限を引き下げます。これらについては ASP.NET Core でのアップロードのサイズ制限で説明しています。

同じルートを最小 API で

最小 API のサンプルでは、MapPost でルートをマップします。属性はエンドポイントのメタデータになり、WithFormOptions がマルチパートの制限を設定します。

C# · RteEndpoints.cs
rte.MapPost("/upload", async (HttpContext http, IFormFile? file, [FromForm] string? folder) =>
    Json(await RteFiles.Save(http, file, folder)))
    .DisableAntiforgery()
    // Kestrel accepts 30,000,000-byte bodies by default; this route needs ~4 MB.
    .WithMetadata(new RequestSizeLimitAttribute(RteFiles.MaxRequestBytes))
    .WithFormOptions(multipartBodyLengthLimit: RteFiles.MaxRequestBytes);

.NET 8 では、.DisableAntiforgery() は省略できません。IFormFile や [FromForm] をバインドするエンドポイントには、偽造防止のメタデータが付きます。app.UseAntiforgery() がないと、そのエンドポイントへの POST はすべて 500("contains anti-forgery metadata, but a middleware was not found")で失敗しました。これを追加すると、トークンのない POST は 400 になりました。エディターのリクエストにはトークンが含まれないため、サンプルではこのチェックを無効にし、代わりにリクエストのオリジンをチェックしています。このチェックについては ASP.NET Core での安全な画像アップロードで説明しています。

ファイルの扱い

どちらのバージョンも同じメソッドを呼び出します。

C# · RteFiles.cs
// POST /rte/upload   file=<image>, folder=<existing folder, optional>
public static Task<RteResult> Save(HttpContext http, IFormFile? file, string? folder) => RunAsync(async () =>
{
    RequireSameOrigin(http.Request);
    var root = RootFor(http);
    folder ??= "";
    var disk = Resolve(root, folder);
    if (file is null || file.Length == 0) throw new RteError(400, "No file was uploaded.");
    if (file.Length > MaxFileBytes) throw new RteError(413, "The image is larger than 4 MB.");

    var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
    if (!Extensions.Contains(ext))
        throw new RteError(400, "Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.");

    // file.ContentType is whatever the client claimed. Check the first bytes instead.
    var head = new byte[12];
    await using (var input = file.OpenReadStream())
        await input.ReadAtLeastAsync(head, head.Length, throwOnEndOfStream: false);
    if (!SignatureMatches(ext, head)) throw new RteError(400, $"The file is not a valid {ext} image.");

    // A new name every time, so an upload never overwrites another file.
    var random = Convert.ToHexString(RandomNumberGenerator.GetBytes(4)).ToLowerInvariant();
    var stored = $"{SafeBaseName(file.FileName)}-{random}{ext}";
    await using (var output = new FileStream(Path.Combine(disk, stored), FileMode.CreateNew))
        await file.CopyToAsync(output);
    return new { url = UrlFor(root, folder, stored) };
});
  • フォルダーは blog のような仮想パスで、uploads/ 以下にすでに存在していなければなりません。ディスク上のパスとして直接使われることはありません。
  • 拡張子は .jpg、.jpeg、.png、.gif、.webp のいずれかで、先頭バイトがそれと一致していなければなりません。file.ContentType は無視します。これはクライアントがリクエストに書いた値にすぎないからです。
  • 保存名は毎回新しく作られます。Red Square.png は red-square-1a2b3c4d.png になります。FileMode.CreateNew により、既存のファイルが上書きされることはありません。
  • ファイルは、5 種類の画像だけを扱う静的ファイルのマッピングによって /uploads/... で配信されます。

問題が起きた場合は、エディターが表示できるステータス付きの JSON が返ります。不正なファイルやフォルダーには 400、クロスサイトのリクエストには 403、存在しないフォルダーには 404、4,000,000 バイトを超えるファイルには 413 です。

テストする

このガイドのサンプルプロジェクトを dotnet run で起動し(.NET 8 SDK が必要です)、http://localhost:5302 を開きます。アップロードボタンとギャラリーの両方が動作します。シェルから、写真をルートとフォルダーに送信します。

Shell
curl -F "file=@photo.jpg" http://localhost:5302/rte/upload
{"url":"/uploads/photo-d78dc40d.jpg"}

curl -F "file=@photo.jpg" -F "folder=blog" http://localhost:5302/rte/upload
{"url":"/uploads/blog/photo-65fc24fe.jpg"}

最小 API のサンプルも、ポート 5301 で同じように応答します。そちらにまだ存在しないフォルダーを指定すると、{"error":"Folder not found."} と 404 が返ります。アップロードではフォルダーが作成されないためです。フォルダーを作成するのはギャラリーの「New folder」ボタンです。

ほかのプラットフォーム向けの同じガイド

JavaScript · Next.js · PHP · Razor Pages · Blazor · MVC 5 · Web Forms

次に読む