Enviar imagens de um editor de texto rico no ASP.NET Core

Uma foto de uma cidade à noite inserida no RichTextEditor depois de ser enviada ao exemplo em ASP.NET Core, com a barra de ferramentas completa acima dela

Quando alguém arrasta, cola ou escolhe uma imagem, o RichTextEditor a envia ao seu servidor como um POST multipart/form-data e insere no texto a URL que recebe de volta. No ASP.NET Core, isso é uma action ou um endpoint de Minimal API que recebe um IFormFile. Esta página mostra as duas formas, o que o servidor verifica antes de salvar qualquer coisa e as respostas que você pode testar com curl.

A página

O editor é carregado de /richtexteditor/. Um pequeno script compartilhado, rte-files.js, o conecta ao servidor: ele define o handler de upload do editor para arrastar e soltar, colar e a caixa de diálogo Insert Image, e também o que a galeria usa para enviar arquivos para a pasta aberta. Os dois fazem POST para /rte/upload e leem {"url": "..."} da resposta.

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 faz o editor recusar um arquivo acima de 4,000,000 bytes antes de enviá-lo. O servidor ainda verifica o tamanho por conta própria.

A action do controller

No exemplo MVC, a rota é uma action em um [ApiController]. O [ApiController] faz sozinho o binding de um parâmetro IFormFile a partir do formulário.

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

O parâmetro é IFormFile? de propósito. Com os nullable reference types ativados, um parâmetro não anulável é tratado como obrigatório, e uma requisição sem arquivo receberia o ProblemDetails do [ApiController] em vez do {"error": "..."} que o editor exibe. Os dois atributos reduzem o limite de requisição apenas para esta action. Limites de tamanho de upload no ASP.NET Core trata deles.

A mesma rota como Minimal API

O exemplo de Minimal API mapeia a rota com MapPost. O atributo vira metadado do endpoint, e WithFormOptions define o limite do 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);

.DisableAntiforgery() não é opcional no .NET 8. Um endpoint que faz binding de IFormFile ou [FromForm] carrega metadados de antiforgery. Sem app.UseAntiforgery(), todo POST para ele falhava com um 500 ("contains anti-forgery metadata, but a middleware was not found"). Com ele, um POST sem token recebia um 400. As requisições do editor não levam token, então o exemplo desativa essa verificação e, em vez dela, verifica a origem da requisição. Upload seguro de imagens no ASP.NET Core explica essa verificação.

O que acontece com o arquivo

As duas versões chamam o mesmo método:

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) };
});
  • A pasta é um caminho virtual, como blog, que já precisa existir em uploads/. Ela nunca é usada diretamente como caminho no disco.
  • A extensão precisa ser .jpg, .jpeg, .png, .gif ou .webp, e os primeiros bytes precisam corresponder a ela. file.ContentType é ignorado: é o que o cliente quiser escrever na requisição.
  • O nome armazenado é novo a cada vez: Red Square.png vira red-square-1a2b3c4d.png. FileMode.CreateNew garante que um arquivo existente nunca seja sobrescrito.
  • Os arquivos são servidos em /uploads/... por um mapeamento de arquivos estáticos que só conhece os cinco tipos de imagem.

Os problemas voltam como JSON com um status que o editor consegue exibir: 400 para arquivo ou pasta inválidos, 403 para uma requisição cross-site, 404 para uma pasta inexistente, 413 para um arquivo acima de 4,000,000 bytes.

Teste

Inicie o projeto de exemplo deste guia com dotnet run (ele requer o SDK do .NET 8) e abra http://localhost:5302. O botão de upload e a galeria funcionam. Em um shell, envie uma foto para a raiz e para uma pasta:

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

O exemplo de Minimal API responde da mesma forma na porta 5301. Uma pasta que ainda não existe lá recebe {"error":"Folder not found."} com um 404, porque uploads nunca criam pastas. O botão New folder da galeria cria.

O mesmo guia para outras plataformas

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

Leia a seguir