在 ASP.NET Core 中从富文本编辑器上传图片

用户拖放、粘贴或选择图片时,RichTextEditor 会以 multipart/form-data POST 请求把图片发送到服务器,并把返回的 URL 插入文本。在 ASP.NET Core 中,这只需要一个接收 IFormFile 的操作方法或一个 Minimal API 端点。本页介绍这两种写法、服务器在保存之前检查哪些内容,以及可以用 curl 验证的响应。
页面
编辑器从 /richtexteditor/ 加载。一个很小的共享脚本 rte-files.js 负责把编辑器连接到服务器:它设置编辑器用于拖放、粘贴和 Insert Image 对话框的上传处理程序,以及图片库用来上传到当前打开文件夹的处理程序。两者都 POST 到 /rte/upload,并从响应中读取 {"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 的参数会被视为必需参数,没有文件的请求就会得到 [ApiController] 的 ProblemDetails,而不是编辑器能显示的 {"error": "..."}。这两个特性只为这个操作方法降低请求限制,详见ASP.NET Core 中的上传大小限制。
用 Minimal API 实现同一路由
Minimal API 示例用 MapPost 映射路由。特性变成端点元数据,WithFormOptions 设置 multipart 限制:
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保证绝不覆盖已有文件。 - 文件通过一个静态文件映射在
/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"}Minimal API 示例在端口 5301 上给出同样的响应。如果目标文件夹在那里还不存在,会得到 404 和 {"error":"Folder not found."},因为上传从不创建文件夹。创建文件夹由图片库的 New folder 按钮完成。
其他平台的同一指南
JavaScript · Next.js · PHP · Razor Pages · Blazor · MVC 5 · Web Forms