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

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.
<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 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.
// 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:
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:
// 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 emuploads/. Ela nunca é usada diretamente como caminho no disco. - A extensão precisa ser
.jpg,.jpeg,.png,.gifou.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.pngvirared-square-1a2b3c4d.png.FileMode.CreateNewgarante 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:
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