Bilder als Dateien speichern, nicht als Base64
Weiter: Rich Text mit Bildern zuverlässig speichern oder einen defekten Upload untersuchen.
Hat der Editor kein Ziel für ein Bild, schreibt er es als Base64-Text in das HTML. Für eine Demo ist das in Ordnung, in der Produktion ein Problem: Ein einziges Smartphone-Foto fügt jedem gespeicherten Beitrag Megabytes hinzu, füllt Datenbankspalten, verlangsamt jede Seite, die den Beitrag lädt, und wird in jede Version und jedes Backup erneut kopiert. Diese Anleitung richtet die Alternative von Anfang bis Ende ein: einen Bildordner auf Ihrem Server, Uploads dorthin, eine Galerie, die ihn durchsucht, abgeschaltetes Base64, eine Prüfung auf dem Server und eine Migration für Beiträge, die bereits Base64 enthalten.
Was Base64 kostet
| Bild | Gespeichert | Im gespeicherten HTML |
|---|---|---|
| Ein Foto mit 4000 x 3000 (JPEG), unverändert eingebettet | Datei mit 4.3 MB | 5.8 MB Text im gespeicherten Beitrag |
| Dasselbe Foto, hochgeladen | Datei mit 4.3 MB auf der Festplatte | etwa 60 Zeichen: <img src="/imageuploads/…jpg"> |
| Dasselbe Foto, von Core 2.6.5 ohne Handler eingebettet | verkleinert auf 1600 x 1200, 129 KB | 172 KB Text |
Base64 ist ein Drittel größer als die Datei, die es kodiert, und es wird im Dokument gespeichert, daher fallen die Kosten bei jedem Lesen des Beitrags an, nicht nur einmal.
Was der Editor ohne Upload-Handler tut
Core 2.6.5 und neuer begrenzt und verkleinert Base64 standardmäßig:
- Ein Foto wird im Browser verkleinert (längste Seite 1600 px, bei Bedarf weiter) und neu kodiert, bevor es eingebettet wird. Ein typisches Smartphone-Foto landet deutlich unter dem Limit.
- Alles, was danach noch größer als 200 KB ist, wird mit einer Meldung abgelehnt, die auf den Upload-Handler verweist.
- GIF (Animation) und SVG werden nie neu kodiert; ein kleines Bild wird unverändert eingebettet.
- Core 2.6.6 und neuer wenden dieselben Regeln auf Bilder innerhalb eingefügter Inhalte an: aus Word eingefügte Bilder (die frühere Cores verloren) und Base64-Bilder in HTML, das von einer Webseite kopiert wurde. Ein abgelehntes oder nicht hochladbares Bild wird verworfen, der eingefügte Text bleibt erhalten, und der Benutzer sieht eine einzige Meldung für den gesamten Einfügevorgang. In 2.6.5 gelangten diese Bilder unverändert hinein, behalten Sie daher die Prüfung auf dem Server aus Schritt 5 bei.
- Beim ersten Einbetten von Base64 auf einer Seite zeigt die Browserkonsole eine Warnung, sodass ein fehlender Handler schon während der Entwicklung auffällt.
- Ist ein Upload-Handler konfiguriert und schlägt der Upload fehl, wird das Bild entfernt, und der Benutzer wird informiert. Ältere Cores behielten stattdessen die Base64-Vorschau in voller Größe, und so gelangte Base64 auch auf Websites mit Handler in Datenbanken.
// 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 zusammen mit base64ImageShrink = false stellt das alte Verhalten exakt wieder her, falls Sie darauf angewiesen sind.
Schritt 1: Den Bildordner anlegen
Alles Folgende verwendet einen Ordner, imageuploads, ausgeliefert unter der URL /imageuploads. Uploads landen darin, die Galerie durchsucht ihn, und seine Unterordner sind die Ordner der 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| Plattform | Ordner | Braucht Schreibzugriff | Vollständige Anleitung |
|---|---|---|---|
| ASP.NET Core | wwwroot/imageuploads | der App-Pool / das Dienstkonto | Artikel zu ASP.NET Core |
| ASP.NET (Web Forms, MVC 5) | ~/imageuploads | IIS AppPool\<Ihr Pool> | Artikel zu ASP.NET (Web Forms, MVC 5) |
| PHP | imageuploads/ next to the scripts | www-data (Benutzer von Apache/nginx) | Artikel zu PHP |
Geben Sie dem Konto des Webservers nur für diesen Ordner Schreibzugriff, nicht für den Rest der Website. Stellen Sie dann sicher, dass darin nie etwas als Code laufen kann und dass Browser jede Datei genau als den Typ behandeln, den sie angibt:
<!-- 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";
}
}
});Schritt 2: Die Endpoints für Upload und Galerie hinzufügen
Jeder Plattformartikel enthält vollständigen, getesteten Servercode für drei Requests. Kopieren Sie den für Ihre Plattform; der Rest dieser Anleitung geht von diesen URLs aus:
| Plattform | Upload (Datei hinein, URL heraus) | 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 |
Der Upload-Endpoint antwortet mit READY:<url> oder ERROR:<message>. Er muss die Dateiendung und die ersten Bytes der Datei prüfen, SVG ablehnen, einen eigenen Dateinamen wählen, statt dem hochgeladenen zu vertrauen, und einen Parameter folder erst annehmen, nachdem er geprüft hat, dass dieser innerhalb von imageuploads bleibt. Der Code im Artikel tut all das. Das JSON, das die Galerie erwartet, ist in Pfad der Bildergalerie beschrieben.
Schritt 3: Den Editor verbinden und Base64 abschalten
Binden Sie rte-upload.js aus dem Artikel zu Ihrer Plattform ein (es definiert rte_file_upload_handler, verwendet für Einfügen, Einfügen aus der Zwischenablage, Drag & Drop und die Schaltfläche „Replace“ des Bildes) sowie richTextBoxUploadFile aus dem Artikel, damit die Upload-Schaltfläche der Galerie Dateien in den gerade durchsuchten Ordner legt. Dann:
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);Mit allowBase64Images = false lehnt eine Seite, deren Upload-Skript nicht geladen werden konnte, Bilder ab, statt sie stillschweigend einzubetten. Die Meldungen können an Ihre eigene Oberfläche gehen:
// 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
};Bei einem Core älter als 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).Schritt 4: Im Browser prüfen
- Fügen Sie über die Upload-Option des Dialogs „Insert Image“ ein großes Foto ein. In der HTML-Ansicht sollte sein
src/imageuploads/…sein, niemalsdata:. - Fügen Sie ein Bild aus der Zwischenablage ein und ziehen Sie eines hinein: dasselbe Ergebnis.
- Öffnen Sie Insert Gallery, legen Sie einen Ordner an, laden Sie hinein hoch und prüfen Sie, ob die Datei in diesem Ordner auf der Festplatte erscheint.
- Stoppen Sie den Upload-Endpoint (oder richten Sie den Handler auf eine falsche URL) und fügen Sie erneut ein: Der Benutzer sieht einen Fehler, und im Text bleibt kein Bild zurück.
- Öffnen Sie
/imageuploads/<a file>im Browser und prüfen Sie, ob die Antwort-HeaderX-Content-Type-Options: nosniffenthalten.
Schritt 5: Base64 auf dem Server ablehnen
Das Bildeditor-Plugin bettet weiterhin eine Daten-URL ein, wenn kein Upload-Handler konfiguriert ist, auch mit allowBase64Images: false. Behalten Sie die folgende serverseitige Prüfung bei.
Einstellungen im Browser schützen ehrliche Benutzer; jeder kann HTML direkt an Ihre Speicher-Action senden. Prüfen Sie vor dem Speichern:
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.");
}Beide erkennen src-Attribute mit und ohne Anführungszeichen.
Schritt 6: Vorhandene Base64-Bilder in Dateien verschieben
Vor der Umstellung gespeicherte Beiträge enthalten ihre Bilder weiterhin eingebettet. Dieser einmalige Job schreibt jedes Bild nach imageuploads/migrated und ersetzt das src durch seine URL. Sichern Sie zuerst die Tabelle und führen Sie den Job einmal auf einer Kopie aus, um das Ergebnis zu prüfen.
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"]));
}- Dateien werden nach dem Hash ihres Inhalts benannt: Ein Bild, das in 500 Beiträgen vorkommt, wird einmal geschrieben, und ein zweiter Lauf ändert nichts.
- Extrahiert werden nur PNG, JPEG, GIF, WebP und BMP. SVG bleibt zur Prüfung an seinem Platz, weil es Skripte enthalten kann.
- Andere Attribute des Bildes (
alt,style,width) bleiben erhalten. - Ein
src=data:…ohne Anführungszeichen wird nicht umgeschrieben; die Prüfung aus Schritt 5 findet es trotzdem.
Referenz der Einstellungen
| Einstellung | Standardwert | Bedeutung |
|---|---|---|
allowBase64Images | true | false: Beim Einfügen von Bildern benötigt der Kern einen Upload-Handler; siehe die Ausnahme für den Bildeditor in Schritt 5. Core 2.6.5+. |
base64ImageMaxSize | 204800 | Größtes eingebettetes Bild in Bytes, nach dem Verkleinern. 0 = kein Limit. Core 2.6.5+. |
base64ImageShrink | true | Fotos vor dem Einbetten verkleinern und neu kodieren. Core 2.6.5+. |
base64ImageMaxDimension | 1600 | Längste Seite eines verkleinerten Fotos in Pixeln. Core 2.6.5+. |
maxUploadFileSize | 0 | Größte Datei, die der Editor einfügt oder hochlädt, in Bytes; wird vor allem anderen geprüft. |
galleryEndpoint | keiner | URL Ihres Galerie-Endpoints; siehe Pfad der Bildergalerie. |
Siehe auch: Dateien hochladen (Handler, Fortschritt, Wiederholungen, Abbruch) und Pfad der Bildergalerie (der JSON-Vertrag der Galerie).