画像を base64 ではなくファイルとして保存する
次に読むもの:画像を含むリッチテキストを確実に保存する、またはうまくいかないアップロードのトラブルシューティング。
画像の送り先がない場合、エディターは画像を base64 のテキストとして HTML に書き込みます。デモならそれで問題ありませんが、本番環境では問題になります。スマートフォンの写真 1 枚で保存する投稿のたびに数メガバイトが加わり、データベースの列を埋め、その投稿を読み込むすべてのページを遅くし、すべてのリビジョンとバックアップに再度コピーされます。このガイドでは、その代わりとなる構成を最初から最後まで作ります。サーバー上の画像フォルダー、そこへのアップロード、それを閲覧するギャラリー、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 はエンコードするファイルより 3 分の 1 大きく、しかも文書の中に保存されるので、そのコストは 1 回ではなく、投稿を読み込むたびにかかります。
アップロードハンドラーがない場合のエディターの動作
core 2.6.5 以降は、既定で base64 に上限を設け、縮小します。
- 写真は、埋め込む前にブラウザーでサイズを変更し(長辺を 1600 px にし、必要ならさらに小さくします)、再エンコードされます。一般的なスマートフォンの写真なら、上限を十分に下回ります。
- それでも 200 KB を超えるものは、アップロードハンドラーを案内するメッセージとともに拒否されます。
- GIF(アニメーション)と SVG は再エンコードされません。小さな画像はそのまま埋め込まれます。
- core 2.6.6 以降は、貼り付けたコンテンツの中の画像にも同じルールを適用します。Word から貼り付けた画像(以前のコアでは失われていました)と、Web ページからコピーした HTML 内の base64 の画像です。拒否された画像やアップロードに失敗した画像は取り除かれ、貼り付けたテキストは残り、ユーザーには貼り付け全体に対して 1 つのメッセージが表示されます。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:画像フォルダーを作成する
以下ではすべて、URL /imageuploads で配信する 1 つのフォルダー 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\<your pool> | 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:アップロードとギャラリーのエンドポイントを追加する
各プラットフォームの記事には、3 種類のリクエストに対応する、完全でテスト済みのサーバーコードがあります。お使いのプラットフォームのものをコピーしてください。このガイドの残りの部分では、次の URL を想定しています。
| プラットフォーム | アップロード(ファイルを受け取り、URL を返す) | ギャラリー(一覧、新しいフォルダー) |
|---|---|---|
| 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(挿入、貼り付け、ドラッグ&ドロップ、画像の「Replace」ボタンで使われる rte_file_upload_handler を定義しています)と、ギャラリーの「Upload」ボタンが閲覧中のフォルダーにファイルを入れるための、記事の richTextBoxUploadFile を読み込みます。次に、以下のようにします。
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 にすると、アップロード用のスクリプトの読み込みに失敗したページでは、画像を黙って埋め込むのではなく拒否します。メッセージは独自の UI に送ることもできます。
// 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 より古いコアでは次のとおりです。
// 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はdata:ではなく/imageuploads/…になっているはずです。 - クリップボードから画像を貼り付け、ドラッグでも画像を入れます。結果は同じになるはずです。
- 「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 の画像をファイルに移す
変更前に保存された投稿には、依然として画像がインラインで含まれています。この 1 回限りの処理は、各画像を 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 件の投稿で使われている画像も書き出されるのは 1 回だけで、2 回目の実行では何も変わりません。
- 取り出すのは 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。画像ギャラリーのパスを参照してください。 |
関連項目:ファイルのアップロード(ハンドラー、進捗、再試行、キャンセル)と、画像ギャラリーのパス(ギャラリーの JSON の仕様)。