← Toda a documentação

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

ImagemArmazenadaNo HTML salvo
Uma foto de 4000 x 3000 (JPEG), incorporada como estáarquivo de 4.3 MB5.8 MB de texto no post salvo
A mesma foto, enviada por uploadarquivo de 4.3 MB em discocerca de 60 caracteres: <img src="/imageuploads/…jpg">
A mesma foto, incorporada pelo core 2.6.5 sem um handlerreduzida para 1600 x 1200, 129 KB172 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 photo

base64ImageMaxSize = 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
PlataformaPastaPrecisa de permissão de escritaPasso a passo completo
ASP.NET Corewwwroot/imageuploadsa conta do app pool / do serviçoArtigo sobre ASP.NET Core
ASP.NET (Web Forms, MVC 5)~/imageuploadsIIS AppPool\<seu pool>Artigo sobre ASP.NET (Web Forms, MVC 5)
PHPimageuploads/ next to the scriptswww-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";
        }
    }
});

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:

PlataformaUpload (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

  1. Insira uma foto grande pela opção Upload da caixa de diálogo Insert Image. Na visualização HTML, o src dela deve ser /imageuploads/…, nunca data:.
  2. Cole uma imagem da área de transferência e arraste outra para o editor: o resultado deve ser o mesmo.
  3. Abra Insert Gallery, crie uma pasta, envie um arquivo para ela e verifique se o arquivo aparece nessa pasta no disco.
  4. 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.
  5. Abra /imageuploads/<a file> no navegador e verifique se os cabeçalhos da resposta incluem X-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çãoPadrãoSignificado
allowBase64Imagestruefalse: 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+.
base64ImageMaxSize204800Tamanho máximo da imagem incorporada, em bytes, após a redução. 0 = sem limite. Core 2.6.5+.
base64ImageShrinktrueRedimensiona e recodifica as fotos antes de incorporá-las. Core 2.6.5+.
base64ImageMaxDimension1600Lado maior, em pixels, de uma foto reduzida. Core 2.6.5+.
maxUploadFileSize0Maior arquivo que o editor vai inserir ou enviar, em bytes; verificado antes de qualquer outra coisa.
galleryEndpointnenhumURL 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).