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

画像がドロップ、貼り付け、または選択されると、RichTextEditor はそれを multipart/form-data の POST としてサーバーに送信し、返ってきた URL を本文に挿入します。ASP.NET Core では、これは IFormFile を受け取る 1 つのアクション、または 1 つの最小 API のエンドポイントです。このページでは、その両方と、サーバーが何かを保存する前にチェックすること、そして curl で確認できるレスポンスを紹介します。
ページ
エディターは /richtexteditor/ から読み込みます。小さな共通スクリプト rte-files.js がエディターをサーバーに接続します。このスクリプトは、ドラッグ&ドロップ、貼り付け、「Insert Image」ダイアログ用のアップロードハンドラーと、ギャラリーが開いているフォルダーにアップロードするときのハンドラーを設定します。どちらも /rte/upload に POST し、レスポンスから {"url": "..."} を読み取ります。
<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>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 パラメーターを自動的にフォームからバインドします。
// 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 がマルチパートの制限を設定します。
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 での安全な画像アップロードで説明しています。
ファイルの扱い
どちらのバージョンも同じメソッドを呼び出します。
// 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 を開きます。アップロードボタンとギャラリーの両方が動作します。シェルから、写真をルートとフォルダーに送信します。
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