Guardar imágenes como archivos, no en base64
A continuación: guardar texto enriquecido con imágenes de forma fiable o solucionar una carga que no funciona.
Cuando el editor no tiene adónde enviar una imagen, la escribe en el HTML como texto en base64. Eso está bien para una demo y es un problema en producción: una sola foto del teléfono añade megabytes a cada publicación guardada, llena las columnas de la base de datos, ralentiza cada página que carga la publicación y se vuelve a copiar en cada revisión y en cada copia de seguridad. Esta guía configura la alternativa de principio a fin: una carpeta de imágenes en tu servidor, cargas en ella, una galería que la explora, base64 desactivado, una comprobación en el servidor y una migración para las publicaciones que ya contienen base64.
Lo que cuesta base64
| Imagen | Guardado | En el HTML guardado |
|---|---|---|
| Una foto de 4000 x 3000 (JPEG), incrustada tal cual | Archivo de 4.3 MB | 5.8 MB de texto en la publicación guardada |
| La misma foto, subida | Archivo de 4.3 MB en el disco | unos 60 caracteres: <img src="/imageuploads/…jpg"> |
| La misma foto, incrustada por core 2.6.5 sin controlador | reducida a 1600 x 1200, 129 KB | 172 KB de texto |
Base64 ocupa un tercio más que el archivo que codifica, y se guarda dentro del documento, así que el coste se paga en cada lectura de la publicación, no una sola vez.
Qué hace el editor sin un controlador de carga
Core 2.6.5 y posteriores limitan y reducen base64 de forma predeterminada:
- Una foto se redimensiona en el navegador (lado más largo de 1600 px, y más pequeña si hace falta) y se vuelve a codificar antes de incrustarla. Una foto típica de teléfono acaba muy por debajo del límite.
- Todo lo que siga ocupando más de 200 KB se rechaza con un mensaje que remite al controlador de carga.
- GIF (animación) y SVG nunca se vuelven a codificar; una imagen pequeña se incrusta sin cambios.
- Core 2.6.6 y posteriores aplican las mismas reglas a las imágenes dentro del contenido pegado: imágenes pegadas desde Word (que los core anteriores perdían) e imágenes en base64 en HTML copiado de una página web. Una imagen que se rechaza o que no se puede subir se descarta, el texto pegado se conserva y el usuario ve un solo mensaje para todo el pegado. En 2.6.5 entraban sin cambios, así que mantén la comprobación en el servidor del paso 5.
- La primera vez que una página incrusta base64, la consola del navegador muestra una advertencia, para que la falta de controlador se detecte durante el desarrollo.
- Cuando sí hay un controlador de carga configurado y la carga falla, la imagen se elimina y se avisa al usuario. Los core más antiguos conservaban en su lugar la vista previa en base64 a tamaño completo, y así es como el base64 llegaba a las bases de datos en sitios que tenían un controlador.
// Core 2.6.5 defaults - you do not need to write these, they are shown so you can change them.
var config = {};
config.allowBase64Images = true; // false = refuse core image insertion without an upload handler
config.base64ImageMaxSize = 204800; // bytes after shrinking (200 KB); 0 = no cap
config.base64ImageShrink = true; // resize photos in the browser before embedding
config.base64ImageMaxDimension = 1600; // longest side, in pixels, of a shrunk photobase64ImageMaxSize = 0 junto con base64ImageShrink = false restaura exactamente el comportamiento anterior, si dependes de él.
Paso 1: crea la carpeta de imágenes
Todo lo que sigue usa una sola carpeta, imageuploads, servida en la URL /imageuploads. Las cargas van a ella, la galería la explora y sus subcarpetas son las carpetas de la galería:
imageuploads/ upload + gallery root
2026…3f2c.jpg uploaded at the root
products/ made with New Folder
2026…9a1e.png
migrated/ written by step 6| Plataforma | Carpeta | Necesita acceso de escritura | Explicación completa |
|---|---|---|---|
| ASP.NET Core | wwwroot/imageuploads | el grupo de aplicaciones o la cuenta de servicio | Artículo de ASP.NET Core |
| ASP.NET (Web Forms, MVC 5) | ~/imageuploads | IIS AppPool\<your pool> | Artículo de ASP.NET (Web Forms, MVC 5) |
| PHP | imageuploads/ next to the scripts | www-data (usuario de Apache/nginx) | Artículo de PHP |
Da a la cuenta del servidor web acceso de escritura solo a esa carpeta, no al resto del sitio. Después asegúrate de que nada de lo que contiene pueda ejecutarse nunca como código, y de que los navegadores traten cada archivo exactamente como el tipo que dice ser:
<!-- web.config inside imageuploads/ (IIS): serve files, never run them -->
<configuration>
<system.webServer>
<!-- Read only: no script or executable handler runs in this folder -->
<handlers accessPolicy="Read" />
<httpProtocol>
<customHeaders>
<add name="X-Content-Type-Options" value="nosniff" />
<add name="Content-Security-Policy" value="default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox" />
</customHeaders>
</httpProtocol>
</system.webServer>
</configuration># .htaccess inside imageuploads/ (Apache): serve files, never run them
# (a deny rule works under mod_php AND PHP-FPM; "php_flag engine off" breaks FPM hosts with a 500)
<FilesMatch "\.(php|phtml|phar|pl|py|cgi|sh|aspx?|ashx)$">
Require all denied
</FilesMatch>
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set Content-Security-Policy "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox"
</IfModule>
# nginx equivalent, in the server block:
# location /imageuploads/ { location ~ \.php$ { return 403; } add_header X-Content-Type-Options nosniff; }// ASP.NET Core: the same two headers on everything served from /imageuploads.
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = ctx =>
{
if (ctx.Context.Request.Path.StartsWithSegments("/imageuploads"))
{
ctx.Context.Response.Headers["X-Content-Type-Options"] = "nosniff";
ctx.Context.Response.Headers["Content-Security-Policy"] =
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox";
}
}
});Paso 2: añade los endpoints de carga y de galería
El artículo de cada plataforma contiene código de servidor completo y probado para tres solicitudes. Copia el de tu plataforma; el resto de esta guía da por hecho estas URL:
| Plataforma | Carga (entra un archivo, sale una URL) | Galería (listado, New Folder) |
|---|---|---|
| ASP.NET Core | /rte/upload | /rte/gallery |
| ASP.NET (Web Forms, MVC 5) | /RTEUpload.ashx | /RTEGallery.ashx |
| PHP | /rte-upload.php | /rte-gallery.php |
El endpoint de carga responde READY:<url> o ERROR:<message>. Debe comprobar la extensión y los primeros bytes del archivo, rechazar SVG, elegir su propio nombre de archivo en lugar de fiarse del subido y aceptar un parámetro folder solo después de comprobar que se queda dentro de imageuploads. El código del artículo hace todo esto. El JSON que espera la galería se describe en Ruta de la galería de imágenes.
Paso 3: conecta el editor y desactiva base64
Incluye rte-upload.js del artículo de tu plataforma (define rte_file_upload_handler, que se usa para las inserciones, el pegado, arrastrar y soltar y el botón Replace de la imagen), además del richTextBoxUploadFile del artículo, para que el botón Upload de la galería ponga los archivos en la carpeta que se está explorando. Después:
var config = {};
// 1. Store images as files: every insert, paste, drop and Replace goes through this.
// (rte-upload.js from the article for your platform defines it; see step 3.)
// window.rte_file_upload_handler = function (file, callback) { ... };
// 2. Refuse base64 in core image insertion if the handler is missing.
config.allowBase64Images = false;
// 3. Refuse huge files before they are uploaded at all.
config.maxUploadFileSize = 5 * 1024 * 1024; // 5 MB
// 4. Browse the upload folder from the Insert Gallery dialog.
config.galleryEndpoint = "/rte/gallery"; // Web Forms: "/RTEGallery.ashx", PHP: "/rte-gallery.php"
var editor = new RichTextEditor("#div_editor", config);Con allowBase64Images = false, una página cuyo script de carga no se cargó rechaza las imágenes en lugar de incrustarlas sin avisar. Los mensajes pueden ir a tu propia interfaz:
// Show the editor's messages in your own UI instead of alert().
config.oncustomdialog = function (state, name, message) {
// name: "base64disabled", "base64toobig", "uploadtoobig" or "uploadfailed"
if (!/^(base64disabled|base64toobig|uploadtoobig|uploadfailed)$/.test(name)) return;
showMyToast(message);
state.returnValue = true; // handled - the editor shows nothing itself
};En un core anterior a 2.6.5:
// Before core 2.6.5 there is no allowBase64Images. Two settings get you most of the way:
var config = {};
config.maxUploadFileSize = 5 * 1024 * 1024; // refuse huge files outright (core 2.6.1 and later)
config.maxHTMLLength = 60000; // cap the whole post below your column size
// ...and configure an upload handler (step 3), so nothing is embedded in the first place.
// A FAILED upload still leaves the full-size base64 preview in older cores: check on the server (step 5).Paso 4: compruébalo en el navegador
- Inserta una foto grande con la opción Upload del cuadro de diálogo Insert Image. En la vista HTML, su
srcdebe ser/imageuploads/…, nuncadata:. - Pega una imagen desde el portapapeles y arrastra otra: mismo resultado.
- Abre Insert Gallery, crea una carpeta, sube un archivo a ella y comprueba que el archivo aparece en esa carpeta en el disco.
- Detén el endpoint de carga (o apunta el controlador a una URL incorrecta) e inserta de nuevo: el usuario ve un error y no queda ninguna imagen en el texto.
- Abre
/imageuploads/<a file>en el navegador y comprueba que los encabezados de la respuesta incluyenX-Content-Type-Options: nosniff.
Paso 5: rechaza base64 en el servidor
El plugin del editor de imágenes sigue incrustando una URL de datos cuando no hay un controlador de carga configurado, incluso con allowBase64Images: false. Conserva la comprobación del lado del servidor que aparece a continuación.
La configuración del navegador protege a los usuarios honestos; cualquiera puede enviar HTML directamente a tu acción de guardado. Compruébalo antes de guardar:
using System.Text.RegularExpressions;
static class RteImages
{
static readonly Regex AnyDataImage =
new(@"src\s*=\s*[""']?\s*data:image/", RegexOptions.Compiled | RegexOptions.IgnoreCase);
// True when saved HTML still carries a base64 image.
public static bool HasBase64Image(string html) => AnyDataImage.IsMatch(html ?? "");
}
// In the action that saves a post:
if (RteImages.HasBase64Image(model.Body))
return BadRequest("Images must be uploaded, not pasted into the text.");<?php
// True when saved HTML still carries a base64 image.
function rte_has_base64_image($html) {
return preg_match('#src\s*=\s*["\']?\s*data:image/#i', $html) === 1;
}
// In the script that saves a post:
if (rte_has_base64_image($_POST["body"])) {
http_response_code(400);
exit("Images must be uploaded, not pasted into the text.");
}Ambas detectan atributos src con y sin comillas.
Paso 6: pasa a archivos las imágenes en base64 existentes
Las publicaciones guardadas antes del cambio siguen llevando sus imágenes incrustadas. Esta tarea de una sola vez escribe cada imagen en imageuploads/migrated y sustituye el src por su URL. Haz antes una copia de seguridad de la tabla y ejecútala una vez sobre una copia para comprobar el resultado.
using System.Security.Cryptography;
using System.Text.RegularExpressions;
static class RteImageMigration
{
static readonly Dictionary<string, string> Types = new(StringComparer.OrdinalIgnoreCase)
{ ["png"] = "png", ["jpeg"] = "jpg", ["jpg"] = "jpg", ["gif"] = "gif", ["webp"] = "webp", ["bmp"] = "bmp" };
static readonly Regex DataImage = new(
@"(src\s*=\s*[""'])data:image/([a-zA-Z]+);base64,([A-Za-z0-9+/=\s]+)([""'])", RegexOptions.Compiled);
// Moves every base64 image in one post into a file and returns the rewritten HTML.
// physicalDir e.g. Path.Combine(env.WebRootPath, "imageuploads", "migrated")
// urlPrefix e.g. "/imageuploads/migrated"
// The file name is the SHA-1 of the image bytes, so a picture shared by many posts is
// written once, and running the migration twice changes nothing.
public static string MigrateBase64Images(string html, string physicalDir, string urlPrefix)
{
Directory.CreateDirectory(physicalDir);
return DataImage.Replace(html, m =>
{
if (!Types.TryGetValue(m.Groups[2].Value, out var ext)) return m.Value; // e.g. SVG: left alone
byte[] bytes;
try { bytes = Convert.FromBase64String(Regex.Replace(m.Groups[3].Value, @"\s+", "")); }
catch (FormatException) { return m.Value; }
if (bytes.Length == 0) return m.Value;
string name = Convert.ToHexString(SHA1.HashData(bytes)).ToLowerInvariant() + "." + ext;
string path = Path.Combine(physicalDir, name);
if (!File.Exists(path)) File.WriteAllBytes(path, bytes);
return m.Groups[1].Value + urlPrefix.TrimEnd('/') + "/" + name + m.Groups[4].Value;
});
}
}
// One-off job, e.g. with EF Core:
foreach (var post in db.Posts.Where(p => p.Body.Contains("data:image/")))
post.Body = RteImageMigration.MigrateBase64Images(post.Body, migratedDir, "/imageuploads/migrated");
db.SaveChanges();<?php
// Moves every base64 image in one post into a file and returns the rewritten HTML.
// $dir e.g. __DIR__ . "/imageuploads/migrated" $url e.g. "/imageuploads/migrated"
// The file name is the SHA-1 of the image bytes, so a picture shared by many posts is
// written once, and running the migration twice changes nothing. Works on PHP 5.6+.
function rte_migrate_base64_images($html, $dir, $url) {
$types = array("png" => "png", "jpeg" => "jpg", "jpg" => "jpg", "gif" => "gif", "webp" => "webp", "bmp" => "bmp");
if (!is_dir($dir)) mkdir($dir, 0755, true);
return preg_replace_callback(
'#(src\s*=\s*["\'])data:image/([a-zA-Z]+);base64,([A-Za-z0-9+/=\s]+)(["\'])#',
function ($m) use ($types, $dir, $url) {
$type = strtolower($m[2]);
if (!isset($types[$type])) return $m[0]; // e.g. SVG: left alone
$bytes = base64_decode(preg_replace('/\s+/', '', $m[3]), true);
if ($bytes === false || $bytes === "") return $m[0];
$name = sha1($bytes) . "." . $types[$type];
$path = $dir . DIRECTORY_SEPARATOR . $name;
if (!file_exists($path)) file_put_contents($path, $bytes);
return $m[1] . $url . "/" . $name . $m[4];
},
$html);
}
// One-off job, e.g. with PDO:
$rows = $pdo->query("SELECT id, body FROM posts WHERE body LIKE '%data:image/%'");
$save = $pdo->prepare("UPDATE posts SET body = ? WHERE id = ?");
foreach ($rows as $row) {
$save->execute(array(rte_migrate_base64_images($row["body"], __DIR__ . "/imageuploads/migrated",
"/imageuploads/migrated"), $row["id"]));
}- Los archivos se nombran por el hash de su contenido: una imagen usada en 500 publicaciones se escribe una sola vez, y una segunda ejecución no cambia nada.
- Solo se extraen PNG, JPEG, GIF, WebP y BMP. SVG se deja en su sitio para que lo revises, porque puede llevar scripts.
- Los demás atributos de la imagen (
alt,style,width) se conservan. - Un
src=data:…sin comillas no se reescribe; la comprobación del paso 5 lo sigue detectando.
Referencia de ajustes
| Ajuste | Predeterminado | Significado |
|---|---|---|
allowBase64Images | true | false: la inserción de imágenes del núcleo necesita un controlador de carga; consulta la excepción del editor de imágenes en el paso 5. Core 2.6.5+. |
base64ImageMaxSize | 204800 | Tamaño máximo en bytes de una imagen incrustada, después de reducirla. 0 = sin límite. Core 2.6.5+. |
base64ImageShrink | true | Redimensiona y vuelve a codificar las fotos antes de incrustarlas. Core 2.6.5+. |
base64ImageMaxDimension | 1600 | Lado más largo, en píxeles, de una foto reducida. Core 2.6.5+. |
maxUploadFileSize | 0 | Archivo más grande que el editor insertará o subirá, en bytes; se comprueba antes que nada. |
galleryEndpoint | ninguno | URL de tu endpoint de galería; consulta Ruta de la galería de imágenes. |
Relacionado: Subir archivos (controladores, progreso, reintentos, cancelación) y Ruta de la galería de imágenes (el contrato JSON de la galería).