Armazene imagens como arquivos, não em base64
A seguir: salve texto rico com imagens de forma confiável ou resolva problemas de um upload com falha.
Quando o editor não tem para onde enviar uma imagem, ele grava a figura no HTML como texto base64. Isso serve para uma demonstração, mas é um problema em produção: uma única foto de celular acrescenta megabytes a cada post salvo, enche colunas do banco de dados, deixa mais lenta cada página que carrega o post e é copiada de novo em cada revisão e em cada backup. Este guia monta a alternativa do início ao fim: uma pasta de imagens no seu servidor, uploads para ela, uma galeria que a navega, base64 desativado, uma verificação no servidor e uma migração para os posts que já contêm base64.
Quanto custa o base64
| Imagem | Armazenada | No HTML salvo |
|---|---|---|
| Uma foto de 4000 x 3000 (JPEG), incorporada como está | arquivo de 4.3 MB | 5.8 MB de texto no post salvo |
| A mesma foto, enviada por upload | arquivo de 4.3 MB em disco | cerca de 60 caracteres: <img src="/imageuploads/…jpg"> |
| A mesma foto, incorporada pelo core 2.6.5 sem um handler | reduzida para 1600 x 1200, 129 KB | 172 KB de texto |
O base64 é um terço maior que o arquivo que ele codifica e fica armazenado dentro do documento, então o custo é pago a cada leitura do post, e não uma vez só.
O que o editor faz sem um handler de upload
O core 2.6.5 e posteriores limitam e reduzem o base64 por padrão:
- Uma foto é redimensionada no navegador (lado maior com 1600 px, e menor se necessário) e recodificada antes de ser incorporada. Uma foto de celular típica fica bem abaixo do limite.
- Qualquer imagem que ainda passe de 200 KB é recusada, com uma mensagem que indica o handler de upload.
- GIF (animação) e SVG nunca são recodificados; uma imagem pequena é incorporada sem alterações.
- O core 2.6.6 e posteriores aplicam as mesmas regras às imagens dentro de conteúdo colado: figuras coladas do Word (que os cores anteriores perdiam) e imagens base64 em HTML copiado de uma página web. Uma imagem recusada ou cujo upload falha é descartada, o texto colado é mantido e o usuário vê uma única mensagem para toda a colagem. No 2.6.5 essas imagens entravam sem alterações, então mantenha a verificação no servidor do passo 5.
- Na primeira vez que uma página incorpora base64, o console do navegador mostra um aviso, para que a falta de um handler seja percebida durante o desenvolvimento.
- Quando um handler de upload está configurado e o upload falha, a imagem é removida e o usuário é avisado. Os cores mais antigos mantinham a pré-visualização em base64 no tamanho original, e foi assim que o base64 chegou aos bancos de dados de sites que tinham um handler.
// 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 com base64ImageShrink = false restaura exatamente o comportamento antigo, se você depende dele.
Passo 1: crie a pasta de imagens
Tudo a seguir usa uma única pasta, imageuploads, servida na URL /imageuploads. Os uploads vão para ela, a galeria a navega, e as subpastas dela são as pastas da galeria:
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 | Pasta | Precisa de permissão de escrita | Passo a passo completo |
|---|---|---|---|
| ASP.NET Core | wwwroot/imageuploads | a conta do app pool / do serviço | Artigo sobre ASP.NET Core |
| ASP.NET (Web Forms, MVC 5) | ~/imageuploads | IIS AppPool\<seu pool> | Artigo sobre ASP.NET (Web Forms, MVC 5) |
| PHP | imageuploads/ next to the scripts | www-data (usuário do Apache/nginx) | Artigo sobre PHP |
Dê à conta do servidor web permissão de escrita apenas nessa pasta, e não no restante do site. Depois, garanta que nada dentro dela possa ser executado como código, e que os navegadores tratem cada arquivo exatamente como o tipo que ele declara 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";
}
}
});Passo 2: adicione os endpoints de upload e da galeria
Cada artigo de plataforma contém código de servidor completo e testado para três requisições. Copie o da sua plataforma; o restante deste guia pressupõe estas URLs:
| Plataforma | Upload (entra o arquivo, sai a URL) | Galeria (listar, 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 |
O endpoint de upload responde READY:<url> ou ERROR:<message>. Ele precisa verificar a extensão e os primeiros bytes do arquivo, recusar SVG, escolher o próprio nome de arquivo em vez de confiar no nome enviado e aceitar um parâmetro folder somente depois de verificar que ele continua dentro de imageuploads. O código dos artigos faz tudo isso. O JSON que a galeria espera está descrito em Caminho da galeria de imagens.
Passo 3: conecte o editor e desative o base64
Inclua o rte-upload.js do artigo da sua plataforma (ele define rte_file_upload_handler, usado em inserções, colagens, arrastar e soltar e no botão Replace da imagem), além do richTextBoxUploadFile do artigo, para que o botão Upload da galeria coloque os arquivos na pasta que está sendo navegada. Depois:
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);Com allowBase64Images = false, uma página cujo script de upload não carregou recusa as imagens em vez de incorporá-las silenciosamente. As mensagens podem ir para a sua própria interface:
// 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
};Em um core anterior ao 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).Passo 4: verifique no navegador
- Insira uma foto grande pela opção Upload da caixa de diálogo Insert Image. Na visualização HTML, o
srcdela deve ser/imageuploads/…, nuncadata:. - Cole uma imagem da área de transferência e arraste outra para o editor: o resultado deve ser o mesmo.
- Abra Insert Gallery, crie uma pasta, envie um arquivo para ela e verifique se o arquivo aparece nessa pasta no disco.
- Pare o endpoint de upload (ou aponte o handler para uma URL errada) e insira de novo: o usuário vê um erro e nenhuma imagem fica no texto.
- Abra
/imageuploads/<a file>no navegador e verifique se os cabeçalhos da resposta incluemX-Content-Type-Options: nosniff.
Passo 5: recuse base64 no servidor
O plugin de edição de imagens ainda incorpora uma URL de dados quando nenhum handler de upload está configurado, mesmo com allowBase64Images: false. Mantenha a verificação no servidor abaixo.
As configurações do navegador protegem os usuários honestos; qualquer pessoa pode enviar HTML diretamente para a sua ação de salvar. Verifique antes de armazenar:
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.");
}Os dois detectam atributos src com e sem aspas.
Passo 6: transforme as imagens base64 existentes em arquivos
Os posts salvos antes da mudança ainda trazem as imagens embutidas. Esta tarefa, executada uma única vez, grava cada imagem em imageuploads/migrated e substitui o src pela URL dela. Faça antes o backup da tabela e execute-a uma vez em uma cópia para conferir o 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"]));
}- Os arquivos recebem o nome do hash do conteúdo: uma figura usada em 500 posts é gravada uma única vez, e uma segunda execução não altera nada.
- Só PNG, JPEG, GIF, WebP e BMP são extraídos. O SVG fica no lugar para você revisar, porque pode conter script.
- Os outros atributos da imagem (
alt,style,width) são mantidos. - Um
src=data:…sem aspas não é reescrito; a verificação do passo 5 continua a encontrá-lo.
Referência de configurações
| Configuração | Padrão | Significado |
|---|---|---|
allowBase64Images | true | false: a inserção de imagens pelo core precisa de um handler de upload; veja a exceção do editor de imagens no passo 5. Core 2.6.5+. |
base64ImageMaxSize | 204800 | Tamanho máximo da imagem incorporada, em bytes, após a redução. 0 = sem limite. Core 2.6.5+. |
base64ImageShrink | true | Redimensiona e recodifica as fotos antes de incorporá-las. Core 2.6.5+. |
base64ImageMaxDimension | 1600 | Lado maior, em pixels, de uma foto reduzida. Core 2.6.5+. |
maxUploadFileSize | 0 | Maior arquivo que o editor vai inserir ou enviar, em bytes; verificado antes de qualquer outra coisa. |
galleryEndpoint | nenhum | URL do seu endpoint de galeria; veja Caminho da galeria de imagens. |
Relacionados: Enviar arquivos (handlers, progresso, novas tentativas, cancelamento) e Caminho da galeria de imagens (o contrato JSON da galeria).