将图片存储为文件,而不是 base64
下一步:可靠地保存带图片的富文本,或者排查出错的上传。
当编辑器没有地方可以发送图片时,它会把图片以 base64 文本的形式写入 HTML。这在演示中没有问题,但在生产环境中会造成麻烦:一张手机照片会让每篇保存的文章增加几兆字节,塞满数据库列,拖慢每个加载这篇文章的页面,还会被再次复制到每个修订版本和备份中。本指南从头到尾设置一套替代方案:服务器上的一个图片文件夹、上传到其中的功能、浏览它的图片库、关闭 base64、服务器端检查,以及针对已经包含 base64 的文章的迁移。
base64 的代价
| 图片 | 存储 | 在保存的 HTML 中 |
|---|---|---|
| 一张 4000 x 3000 的照片(JPEG),原样嵌入 | 4.3 MB 的文件 | 保存的文章中多出 5.8 MB 的文本 |
| 同一张照片,上传 | 磁盘上 4.3 MB 的文件 | 大约 60 个字符:<img src="/imageuploads/…jpg"> |
| 同一张照片,在没有处理程序的情况下由 core 2.6.5 嵌入 | 缩小到 1600 x 1200,129 KB | 172 KB 的文本 |
base64 比它所编码的文件大三分之一,而且存储在文档内部,因此每次读取文章时都要付出这个代价,而不是只付一次。
没有上传处理程序时编辑器会怎么做
Core 2.6.5 及更高版本默认会限制并缩小 base64:
- 照片在嵌入之前会在浏览器中缩放(最长边 1600 px,必要时更小)并重新编码。一张典型的手机照片最终会远低于上限。
- 任何仍大于 200 KB 的图片都会被拒绝,并显示一条指向上传处理程序的消息。
- GIF(动画)和 SVG 永远不会被重新编码;小图片会原样嵌入。
- Core 2.6.6 及更高版本对粘贴内容中的图片也应用同样的规则:从 Word 粘贴的图片(早期版本会丢失它们)以及从网页复制的 HTML 中的 base64 图片。被拒绝或上传失败的图片会被丢弃,粘贴的文字会保留,用户只会看到一条针对整次粘贴的消息。在 2.6.5 中,这些图片会原样进入,因此请保留第 5 步中的服务器端检查。
- 页面第一次嵌入 base64 时,浏览器控制台会显示一条警告,这样在开发期间就能发现缺少处理程序。
- 当已经配置了上传处理程序而上传失败时,图片会被移除,并告知用户。较早的版本会保留完整尺寸的 base64 预览,这就是 base64 会进入那些已有处理程序的站点数据库的原因。
// 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 和 base64ImageShrink = false 即可完全恢复。
第 1 步:创建图片文件夹
下文的所有内容都使用同一个文件夹 imageuploads,通过 URL /imageuploads 提供。上传的文件放进这里,图片库浏览这里,它的子文件夹就是图片库的文件夹:
imageuploads/ upload + gallery root
2026…3f2c.jpg uploaded at the root
products/ made with New Folder
2026…9a1e.png
migrated/ written by step 6| 平台 | 文件夹 | 需要写入权限的账户 | 完整讲解 |
|---|---|---|---|
| ASP.NET Core | wwwroot/imageuploads | 应用程序池/服务账户 | ASP.NET Core 文章 |
| ASP.NET(Web Forms、MVC 5) | ~/imageuploads | IIS AppPool\<您的应用程序池> | ASP.NET(Web Forms、MVC 5)文章 |
| PHP | imageuploads/ next to the scripts | www-data(Apache/nginx 用户) | PHP 文章 |
只为 Web 服务器的账户授予该文件夹的写入权限,而不是站点的其他部分。然后确保其中的任何内容都不能作为代码运行,并且浏览器会把每个文件严格当作它声明的类型来处理:
<!-- 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";
}
}
});第 2 步:添加上传和图片库端点
每篇平台文章都包含针对三种请求的完整、经过测试的服务器端代码。复制适用于您平台的那一份;本指南的其余部分假定使用以下 URL:
| 平台 | 上传(传入文件,返回 URL) | 图片库(列表、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 |
上传端点返回 READY:<url> 或 ERROR:<message>。它必须检查扩展名以及文件开头的字节,拒绝 SVG,自己选择文件名而不是信任上传的文件名,并且只有在确认 folder 参数位于 imageuploads 之内后才接受它。文章中的代码完成了所有这些工作。图片库期望的 JSON 见图片库路径。
第 3 步:连接编辑器,并关闭 base64
引入您平台文章中的 rte-upload.js(它定义了 rte_file_upload_handler,用于插入、粘贴、拖放以及图片的 Replace 按钮),再加上文章中的 richTextBoxUploadFile,让图片库的 Upload 按钮把文件放进当前浏览的文件夹。然后:
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);设置 allowBase64Images = false 后,如果页面的上传脚本加载失败,编辑器会拒绝图片,而不是悄悄地把它们嵌入。这些消息可以发送到您自己的界面:
// 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
};在早于 2.6.5 的 core 上:
// 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).第 4 步:在浏览器中检查
- 用 Insert Image 对话框的 Upload 选项插入一张大照片。在 HTML 视图中,它的
src应该是/imageuploads/…,绝不能是data:。 - 从剪贴板粘贴一张图片,再拖入一张:结果相同。
- 打开 Insert Gallery,创建一个文件夹,向其中上传,并检查该文件是否出现在磁盘上的对应文件夹中。
- 停止上传端点(或者把处理程序指向一个错误的 URL),然后再次插入:用户会看到一条错误消息,文本中不会留下任何图片。
- 在浏览器中打开
/imageuploads/<a file>,检查响应标头中是否包含X-Content-Type-Options: nosniff。
第 5 步:在服务器上拒绝 base64
即使设置了 allowBase64Images: false,在未配置上传处理程序时,图片编辑器插件仍会嵌入 data URL。因此,请保留下面的服务器端检查。
浏览器端的设置只能保护诚实的用户;任何人都可以直接把 HTML POST 到您的保存操作。请在存储之前进行检查:
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.");
}两者都能捕获带引号和不带引号的 src 属性。
第 6 步:把现有的 base64 图片移到文件中
更改之前保存的文章,图片仍然内联在其中。这个一次性任务会把每张图片写入 imageuploads/migrated,并把 src 替换为它的 URL。请先备份数据表,并在副本上运行一次以检查结果。
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"]));
}- 文件按内容哈希命名:在 500 篇文章中使用的同一张图片只会写入一次,第二次运行不会改变任何东西。
- 只提取 PNG、JPEG、GIF、WebP 和 BMP。SVG 会保留在原处供您审查,因为它可能携带脚本。
- 图片上的其他属性(
alt、style、width)会保留。 - 不带引号的
src=data:…不会被改写;第 5 步的检查仍然能发现它。
设置参考
| 设置 | 默认值 | 含义 |
|---|---|---|
allowBase64Images | true | false:核心插入图片时需要上传处理程序;图片编辑器的例外情况见第 5 步。Core 2.6.5+。 |
base64ImageMaxSize | 204800 | 缩小后嵌入图片的最大字节数。0 = 不设上限。Core 2.6.5+。 |
base64ImageShrink | true | 在嵌入之前缩放并重新编码照片。Core 2.6.5+。 |
base64ImageMaxDimension | 1600 | 缩小后照片最长边的像素数。Core 2.6.5+。 |
maxUploadFileSize | 0 | 编辑器会插入或上传的最大文件字节数;在其他任何检查之前进行检查。 |
galleryEndpoint | 无 | 图片库端点的 URL;请参阅图片库路径。 |