Subir imágenes desde un editor de texto enriquecido en ASP.NET Core

Cuando alguien suelta, pega o elige una imagen, RichTextEditor la envía a tu servidor como un POST multipart/form-data y pone en el texto la URL que recibe de vuelta. En ASP.NET Core, eso es una acción o un endpoint de Minimal API que recibe un IFormFile. Esta página muestra ambos, qué comprueba el servidor antes de guardar nada y las respuestas que puedes probar con curl.
La página
El editor se carga desde /richtexteditor/. Un pequeño script compartido, rte-files.js, lo conecta con el servidor: define el controlador de carga del editor para arrastrar y soltar, pegar y el cuadro de diálogo Insert Image, y el que usa la galería para subir archivos a la carpeta abierta. Ambos envían a /rte/upload y leen {"url": "..."} de la respuesta.
<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 hace que el editor rechace un archivo de más de 4,000,000 bytes antes de enviarlo. El servidor comprueba el tamaño de todos modos.
La acción del controlador
En el ejemplo de MVC, la ruta es una acción de un [ApiController]. [ApiController] enlaza por sí solo un parámetro IFormFile desde el formulario.
// 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));El parámetro es IFormFile? a propósito. Con los tipos de referencia que aceptan valores NULL activados, un parámetro no nullable se trata como obligatorio, y una solicitud sin archivo recibiría los ProblemDetails de [ApiController] en lugar del {"error": "..."} que muestra el editor. Los dos atributos reducen el límite de la solicitud solo para esta acción. Límites de tamaño de carga en ASP.NET Core los explica.
La misma ruta como Minimal API
El ejemplo de Minimal API asigna la ruta con MapPost. El atributo se convierte en metadatos del endpoint, y WithFormOptions establece el límite 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() no es opcional en .NET 8. Un endpoint que enlaza IFormFile o [FromForm] lleva metadatos antiforgery. Sin app.UseAntiforgery(), todos los POST a él fallaban con un 500 ("contains anti-forgery metadata, but a middleware was not found"). Con él, un POST sin token recibía un 400. Las solicitudes del editor no llevan token, así que el ejemplo desactiva esa comprobación y comprueba en su lugar el origen de la solicitud. Carga segura de imágenes en ASP.NET Core explica esa comprobación.
Qué pasa con el archivo
Las dos versiones llaman al mismo 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) };
});- La carpeta es una ruta virtual como
blogque ya debe existir dentro deuploads/. Nunca se usa directamente como ruta de disco. - La extensión debe ser
.jpg,.jpeg,.png,.gifo.webp, y los primeros bytes deben coincidir con ella.file.ContentTypese ignora: es lo que el cliente haya escrito en la solicitud. - El nombre guardado es nuevo cada vez:
Red Square.pngse convierte enred-square-1a2b3c4d.png.FileMode.CreateNewsignifica que nunca se sobrescribe un archivo existente. - Los archivos se sirven en
/uploads/...mediante una asignación de archivos estáticos que solo conoce los cinco tipos de imagen.
Los problemas vuelven como JSON con un estado que el editor puede mostrar: 400 para un archivo o una carpeta incorrectos, 403 para una solicitud entre sitios, 404 para una carpeta que no existe, 413 para un archivo de más de 4,000,000 bytes.
Pruébalo
Inicia el proyecto de ejemplo de esta guía con dotnet run (necesita el SDK de .NET 8) y abre http://localhost:5302. Funcionan tanto el botón de carga como la galería. Desde un shell, envía una foto a la raíz y a una carpeta:
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"}El ejemplo de Minimal API responde igual en el puerto 5301. Una carpeta que todavía no existe ahí recibe {"error":"Folder not found."} con un 404, porque las cargas nunca crean carpetas. Eso lo hace el botón New folder de la galería.
La misma guía para otras plataformas
JavaScript · Next.js · PHP · Razor Pages · Blazor · MVC 5 · Web Forms