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

从 MVC 5 示例的图片库插入 RichTextEditor 的一张图片,上方是完整的工具栏

RichTextEditor 会把每张图片 POST 到服务器,并插入返回的 URL。在 ASP.NET MVC 5 中,这需要一个路由、一个控制器操作方法和一个 HttpPostedFileBase。最直观的写法是把文件作为操作方法的参数,但遇到 ASP.NET 请求验证不接受的文件名时就会失败。本页介绍可行的操作方法写法及其所需的路由,以及如何在不使用 Visual Studio 的情况下构建示例。

路由

rte-files.js 是示例项目中的浏览器端文件。它以 multipart/form-data 格式 POST 到 /rte/upload,文件放在名为 file 的字段中,另有一个可选的 folder 字段,并期望返回 {"url": "..."}。在 Global.asax.cs 中,两个显式路由放在默认的 {controller}/{action}/{id} 之前:

C# · Global.asax.cs
// The two endpoints rte-files.js and the gallery dialog call.
routes.MapRoute("RteGallery", "rte/gallery", new { controller = "Rte", action = "Gallery" });
routes.MapRoute("RteUpload", "rte/upload", new { controller = "Rte", action = "Upload" });

视图通过 Url.Content 加载编辑器和 rte-files.js。这样,站点运行在 IIS 虚拟目录中时,路径仍然有效:

Razor · Views/Home/Index.cshtml
<link rel="stylesheet" href="@Url.Content("~/richtexteditor/rte_theme_default.css")" />
<script src="@Url.Content("~/richtexteditor/rte.js")"></script>
<script src="@Url.Content("~/richtexteditor/plugins/all_plugins.js")"></script>
<script>window.RTE_FILES_URL = "@Url.Content("~/rte")";</script>
<script src="@Url.Content("~/rte-files.js")"></script>

操作方法

C# · Controllers/RteController.cs
[HttpPost]
public ActionResult Upload()
{
    try
    {
        // Not action parameters: MVC binds HttpPostedFileBase from the *validated*
        // Request.Files even under [ValidateInput(false)], so a file named "<b>.png"
        // would fail with an HTML 500 before this code runs.
        HttpPostedFileBase file = Request.Unvalidated.Files["file"];
        string folder = Request.Unvalidated.Form["folder"];
        string url = RteFiles.SaveUpload(HttpContext, file, folder);
        return JsonStatus(200, new { url = url });
    }
    catch (RteException ex)
    {
        return JsonStatus(ex.Status, new { error = ex.Message });
    }
}

文件不是操作方法的参数,这是有意为之。控制器带有 [ValidateInput(false)],因为它会自己验证每个值。但即使有这个特性,MVC 仍会从经过验证的 Request.Files 绑定 HttpPostedFileBase 参数。使用 Upload(HttpPostedFileBase file, string folder) 时,名为 <script>x.png 的文件会得到一个 HTML 500 页面,内容为 "A potentially dangerous Request.Files value was detected from the client"。这发生在操作方法运行之前,因此不可能返回 JSON 错误。Request.Unvalidated.Files["file"] 返回同样的 HttpPostedFileBase,但不做这项检查。改用它之后,同一个请求会保存为 script-x-xxxxxxxx.png。

错误都通过同一个辅助方法返回,因此每个响应都是带有正确状态码的 JSON:

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
    };
}

服务器检查什么

规则写在 RteFiles.cs 中。它只使用 HttpContextBase 和 HttpPostedFileBase,因此在任何控制器中都能同样使用:

C# · RteFiles.cs
StorageRoot root = GetRoot(context);   // 401 here first in per-user mode
if (file == null || file.ContentLength == 0)
    throw new RteException(400, "No file was uploaded.");

// FileName is whatever the browser sent (old IE sends a full path). Don't feed it
// to Path.GetFileName: on .NET Framework that throws on characters like " < > |.
string clientName = file.FileName ?? "";
clientName = clientName.Substring(Math.Max(clientName.LastIndexOf('/'), clientName.LastIndexOf('\\')) + 1);
int dot = clientName.LastIndexOf('.');
string ext = dot >= 0 ? clientName.Substring(dot).ToLowerInvariant() : "";
if (Array.IndexOf(ImageExtensions, ext) < 0)
    throw new RteException(400, "Only .jpg, .jpeg, .png, .gif and .webp images can be uploaded.");
if (file.ContentLength > MaxUploadBytes)
    throw new RteException(413, "The image is larger than the 4 MB (4,000,000 bytes) limit.");

string dir = ResolveFolder(root, folder ?? "");

byte[] data = new byte[file.ContentLength];
Stream input = file.InputStream;
int read = 0;
while (read < data.Length)
{
    int n = input.Read(data, read, data.Length - read);
    if (n <= 0) break;
    read += n;
}
// file.ContentType is chosen by the browser (or the attacker), so look at the bytes.
if (read != data.Length || !SignatureMatches(ext, data))
    throw new RteException(400, "The file is not a valid " + ext.Substring(1).ToUpperInvariant() + " image.");

检查内容包括:图片扩展名、不超过 4,000,000 字节,以及文件开头的字节与扩展名相符。ContentType 会被忽略,因为它由浏览器决定。客户端文件名在最后一个斜杠处手动截断,因为 .NET Framework 的 Path.GetFileName 遇到 < 和 | 这样的字符会抛出异常。之后文件以随机名称写入:

C# · RteFiles.cs
string baseName = SanitizeBaseName(dot >= 0 ? clientName.Substring(0, dot) : clientName);
for (int attempt = 0; ; attempt++)
{
    string stored = baseName + "-" + RandomHex(4) + ext;
    string path = Path.Combine(dir, stored);
    try
    {
        // CreateNew: never overwrite an existing file, even on a random-name collision.
        using (FileStream output = new FileStream(path, FileMode.CreateNew, FileAccess.Write))
            output.Write(data, 0, data.Length);
        return UrlFor(root, folder ?? "", stored);
    }
    catch (IOException)
    {
        if (attempt >= 4 || !File.Exists(path)) throw;
    }
}

不使用 Visual Studio 构建

示例是一个面向 net48 的 SDK 风格项目。它输出到 IIS 期望的程序集位置 bin\,并引用 Microsoft.AspNet.Mvc 5.3.0。它还引用了 Microsoft.NETFramework.ReferenceAssemblies,因此不需要安装目标包。从一个没有 bin 或 obj 的文件夹开始,dotnet build 还原了包,把 System.Web.Mvc.dll、Razor 和 WebPages 复制到 bin\,IIS Express 随后成功运行了站点。和所有 MVC 5 应用一样,Razor 视图由 ASP.NET 在运行时编译。控制器和 RteFiles.cs 也可以直接放入使用 packages.config 的现有 Visual Studio 项目中。

测试

用 powershell -ExecutionPolicy Bypass -File run.ps1 -EditorPath <your richtexteditor folder> 运行本指南的示例项目。它先运行 dotnet build,再在 http://localhost:5402/ 启动 IIS Express。然后执行:

Shell
curl -F "file=@harbor.jpg" http://localhost:5402/rte/upload
{"url":"/uploads/harbor-fb0799c5.jpg"}

echo "<script>alert(1)</script>" > fake.png
curl -F "file=@fake.png" http://localhost:5402/rte/upload
{"error":"The file is not a valid PNG image."}

两个响应分别是 HTTP 200 和 HTTP 400。在浏览器中,依次点击 Insert Gallery 和 Upload 会发送同样的请求,点击 Insert 则把图片插入文本。对于被拒绝的文件,对话框显示的是它自己的 "Upload failed for <name>.",而不是服务器返回的消息。

其他平台的同一指南

JavaScript · Next.js · PHP · ASP.NET Core · Razor Pages · Blazor · Web Forms

延伸阅读