Stocker les images sous forme de fichiers, pas en base64
Ensuite : enregistrer de façon fiable du texte enrichi avec des images, ou résoudre un envoi défaillant.
Quand l’éditeur ne sait pas où envoyer une image, il l’écrit dans le HTML sous forme de texte base64. C’est acceptable pour une démo et problématique en production : une seule photo de téléphone ajoute des mégaoctets à chaque article enregistré, remplit les colonnes de la base de données, ralentit chaque page qui charge l’article, et se retrouve de nouveau copiée dans chaque révision et chaque sauvegarde. Ce guide met en place l’alternative de bout en bout : un dossier d’images sur votre serveur, des envois dans ce dossier, une galerie qui le parcourt, le base64 désactivé, une vérification côté serveur et une migration pour les articles qui contiennent déjà du base64.
Ce que coûte le base64
| Image | Stockée | Dans le HTML enregistré |
|---|---|---|
| Une photo de 4000 x 3000 (JPEG), intégrée telle quelle | fichier de 4.3 Mo | 5.8 Mo de texte dans l’article enregistré |
| La même photo, envoyée au serveur | fichier de 4.3 Mo sur le disque | environ 60 caractères : <img src="/imageuploads/…jpg"> |
| La même photo, intégrée par core 2.6.5 sans gestionnaire | réduite à 1600 x 1200, 129 Ko | 172 Ko de texte |
Le base64 est plus gros d’un tiers que le fichier qu’il encode, et il est stocké dans le document : le coût est donc payé à chaque lecture de l’article, et non une seule fois.
Ce que fait l’éditeur sans gestionnaire d’envoi
Core 2.6.5 et versions ultérieures plafonne et réduit le base64 par défaut :
- Une photo est redimensionnée dans le navigateur (côté le plus long à 1600 px, puis davantage si nécessaire) et réencodée avant d’être intégrée. Une photo de téléphone typique finit bien en dessous du plafond.
- Tout ce qui dépasse encore 200 KB est refusé, avec un message qui renvoie vers le gestionnaire d’envoi.
- Les GIF (animation) et les SVG ne sont jamais réencodés ; une petite image est intégrée sans modification.
- Core 2.6.6 et versions ultérieures appliquent les mêmes règles aux images contenues dans le contenu collé : les images collées depuis Word (que les cœurs précédents perdaient) et les images base64 du HTML copié depuis une page web. Une image refusée ou dont l’envoi échoue est abandonnée, le texte collé est conservé, et l’utilisateur voit un seul message pour tout le collage. En 2.6.5, ces images passaient sans modification : conservez donc la vérification côté serveur de l’étape 5.
- La première fois qu’une page intègre du base64, la console du navigateur affiche un avertissement, pour qu’un gestionnaire manquant soit repéré pendant le développement.
- Lorsqu’un gestionnaire d’envoi est configuré et que l’envoi échoue, l’image est supprimée et l’utilisateur en est informé. Les cœurs plus anciens conservaient à la place l’aperçu base64 en taille réelle, et c’est ainsi que du base64 se retrouvait dans les bases de données de sites qui avaient pourtant un gestionnaire.
// 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 associé à base64ImageShrink = false rétablit exactement l’ancien comportement, si vous en dépendez.
Étape 1 : créer le dossier d’images
Tout ce qui suit utilise un seul dossier, imageuploads, servi à l’URL /imageuploads. Les envois y vont, la galerie le parcourt, et ses sous-dossiers sont les dossiers de la galerie :
imageuploads/ upload + gallery root
2026…3f2c.jpg uploaded at the root
products/ made with New Folder
2026…9a1e.png
migrated/ written by step 6| Plateforme | Dossier | Doit avoir l’accès en écriture | Présentation complète |
|---|---|---|---|
| ASP.NET Core | wwwroot/imageuploads | le compte du pool d’applications / du service | Article ASP.NET Core |
| ASP.NET (Web Forms, MVC 5) | ~/imageuploads | IIS AppPool\<votre pool> | Article ASP.NET (Web Forms, MVC 5) |
| PHP | imageuploads/ next to the scripts | www-data (utilisateur Apache/nginx) | Article PHP |
Donnez au compte du serveur web l’accès en écriture à ce dossier uniquement, et non au reste du site. Assurez-vous ensuite que rien ne puisse jamais s’y exécuter comme du code, et que les navigateurs traitent chaque fichier exactement comme le type qu’il annonce :
<!-- 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";
}
}
});Étape 2 : ajouter les points de terminaison d’envoi et de galerie
Chaque article de plateforme contient un code serveur complet et testé pour trois requêtes. Copiez celui de votre plateforme ; la suite de ce guide suppose ces URL :
| Plateforme | Envoi (fichier en entrée, URL en sortie) | Galerie (liste, 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 |
Le point de terminaison d’envoi répond READY:<url> ou ERROR:<message>. Il doit vérifier l’extension et les premiers octets du fichier, refuser le SVG, choisir lui-même le nom du fichier au lieu de faire confiance au nom envoyé, et n’accepter un paramètre folder qu’après avoir vérifié qu’il reste à l’intérieur d'imageuploads. Le code des articles fait tout cela. Le JSON qu’attend la galerie est décrit dans Chemin de la galerie d’images.
Étape 3 : relier l’éditeur et désactiver le base64
Incluez le fichier rte-upload.js de l’article de votre plateforme (il définit rte_file_upload_handler, utilisé pour les insertions, le collage, le glisser-déposer et le bouton Replace de l’image), ainsi que le richTextBoxUploadFile de l’article, pour que le bouton Upload de la galerie place les fichiers dans le dossier parcouru. Ensuite :
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);Avec allowBase64Images = false, une page dont le script d’envoi n’a pas pu se charger refuse les images au lieu de les intégrer discrètement. Les messages peuvent être envoyés vers votre propre 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
};Sur un cœur antérieur à 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).Étape 4 : vérifier dans le navigateur
- Insérez une grande photo avec l’option Upload de la boîte de dialogue Insert Image. Dans la vue HTML, son
srcdoit être/imageuploads/…, jamaisdata:. - Collez une image depuis le presse-papiers et faites-en glisser une : même résultat.
- Ouvrez Insert Gallery, créez un dossier, envoyez un fichier dedans, et vérifiez que le fichier apparaît dans ce dossier sur le disque.
- Arrêtez le point de terminaison d’envoi (ou faites pointer le gestionnaire vers une mauvaise URL) et insérez de nouveau une image : l’utilisateur voit une erreur, et aucune image ne reste dans le texte.
- Ouvrez
/imageuploads/<a file>dans le navigateur et vérifiez que les en-têtes de la réponse contiennentX-Content-Type-Options: nosniff.
Étape 5 : refuser le base64 sur le serveur
Le plugin d’édition d’images intègre toujours une URL de données lorsqu’aucun gestionnaire d’envoi n’est configuré, même avec allowBase64Images: false. Conservez la vérification côté serveur ci-dessous.
Les réglages du navigateur protègent les utilisateurs honnêtes ; n’importe qui peut envoyer du HTML directement à votre action d’enregistrement. Vérifiez avant de stocker :
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.");
}Les deux interceptent les attributs src avec ou sans guillemets.
Étape 6 : transformer les images base64 existantes en fichiers
Les articles enregistrés avant le changement contiennent toujours leurs images en ligne. Ce traitement ponctuel écrit chaque image dans imageuploads/migrated et remplace le src par son URL. Sauvegardez d’abord la table, et exécutez-le une première fois sur une copie pour vérifier le résultat.
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"]));
}- Les fichiers sont nommés d’après le hash de leur contenu : une image utilisée dans 500 articles est écrite une seule fois, et une seconde exécution ne change rien.
- Seuls les PNG, JPEG, GIF, WebP et BMP sont extraits. Les SVG sont laissés en place pour que vous les examiniez, car ils peuvent contenir du script.
- Les autres attributs de l’image (
alt,style,width) sont conservés. - Un
src=data:…sans guillemets n’est pas réécrit ; la vérification de l’étape 5 le trouve quand même.
Référence des réglages
| Réglage | Valeur par défaut | Signification |
|---|---|---|
allowBase64Images | true | false : l’insertion d’images par le cœur nécessite un gestionnaire d’envoi ; voir l’exception du plugin d’édition d’images à l’étape 5. Core 2.6.5+. |
base64ImageMaxSize | 204800 | Taille maximale d’une image intégrée, en octets, après réduction. 0 = aucun plafond. Core 2.6.5+. |
base64ImageShrink | true | Redimensionner et réencoder les photos avant l’intégration. Core 2.6.5+. |
base64ImageMaxDimension | 1600 | Côté le plus long, en pixels, d’une photo réduite. Core 2.6.5+. |
maxUploadFileSize | 0 | Taille maximale, en octets, d’un fichier que l’éditeur insère ou envoie ; vérifiée avant tout le reste. |
galleryEndpoint | aucune | URL de votre point de terminaison de galerie ; voir Chemin de la galerie d’images. |
Voir aussi : Téléverser des fichiers (gestionnaires, progression, nouvelles tentatives, annulation) et Chemin de la galerie d’images (le contrat JSON de la galerie).