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

上传到 ASP.NET Core 示例后插入 RichTextEditor 的一张城市夜景照片,上方是完整的工具栏

用户拖放、粘贴或选择图片时,RichTextEditor 会以 multipart/form-data POST 请求把图片发送到服务器,并把返回的 URL 插入文本。在 ASP.NET Core 中,这只需要一个接收 IFormFile 的操作方法或一个 Minimal API 端点。本页介绍这两种写法、服务器在保存之前检查哪些内容,以及可以用 curl 验证的响应。

页面

编辑器从 /richtexteditor/ 加载。一个很小的共享脚本 rte-files.js 负责把编辑器连接到服务器:它设置编辑器用于拖放、粘贴和 Insert Image 对话框的上传处理程序,以及图片库用来上传到当前打开文件夹的处理程序。两者都 POST 到 /rte/upload,并从响应中读取 {"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 的参数会被视为必需参数,没有文件的请求就会得到 [ApiController] 的 ProblemDetails,而不是编辑器能显示的 {"error": "..."}。这两个特性只为这个操作方法降低请求限制,详见ASP.NET Core 中的上传大小限制。

用 Minimal API 实现同一路由

Minimal API 示例用 MapPost 映射路由。特性变成端点元数据,WithFormOptions 设置 multipart 限制:

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 保证绝不覆盖已有文件。
  • 文件通过一个静态文件映射在 /uploads/... 下提供,该映射只识别这五种图片类型。

出现问题时,服务器返回 JSON 和编辑器能显示的状态码:文件或文件夹无效返回 400,跨站请求返回 403,文件夹不存在返回 404,文件超过 4,000,000 字节返回 413。

测试

用 dotnet run 启动本指南的示例项目(需要 .NET 8 SDK),然后打开 http://localhost:5302。上传按钮和图片库都能正常使用。在 shell 中分别把一张照片发送到根目录和某个文件夹:

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

延伸阅读