はじめに
WYSIWYGエディタの Summernote は、画像を挿入するとデフォルトでは base64に変換してHTMLに直接埋め込みます。
base64のままだと画像がテキストとして本文に含まれてしまうため、画像ファイルのままAWSのストレージであるS3に保存したいと思います。
今回は、Summernoteで挿入した画像を S3の署名付きURLを使ってブラウザから直接アップロードする仕組み を作っていきます。
処理フロー
処理の流れは以下のようになっています。
ポイントとしては、ブラウザから直接S3にアップロードするので、画像データがサーバーを経由しません。
また、AWSの認証情報はサーバー側だけで持つので、フロントに漏れることもありません。
完成イメージ
こんな感じで画像をドラッグ&ドロップするだけでS3にアップロードできます。
環境
| 項目 | バージョン |
|---|---|
| Node.js | 20.6以上(--env-fileを使用) |
| Express | 4.x |
| AWS SDK for JavaScript | v3 |
| Summernote | 0.9.1(lite版) |
AWS構築
AWS側は、IAM と S3 の設定が必要です。
IAM設定
サーバーが署名付きURLを発行するためのIAMユーザーを作成します。
署名付きURLは「発行したユーザーの権限」でアップロードされるため、このユーザーにs3:PutObjectの権限が必要になります。
ポリシーの作成
uploads/配下へのアップロードと削除だけを許可するポリシーを作成します。
[IAM] → [ポリシー] → [ポリシーを作成] から、JSONタブに以下を貼り付けます。
※{bucket-name}はバケット名を指定します
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::{bucket-name}/uploads/*"
}
]
}
ユーザーの作成
[IAM] → [ユーザー] → [ユーザーの作成] から作成します。
- コンソールへのアクセスは不要なので、チェックは入れません
- 許可の設定で [ポリシーを直接アタッチする] を選び、先ほど作成したポリシーをアタッチします
アクセスキーの発行
作成したユーザーの [セキュリティ認証情報] → [アクセスキーを作成] から発行します。
ユースケースは「ローカルコード」を選択します。
Express上の.envの設定
発行したキーは.envに設定します。
※{bucket-name}はバケット名を指定します
AWS_REGION=ap-northeast-1
S3_BUCKET={bucket-name}
PUBLIC_BASE_URL=https://{bucket-name}.s3.ap-northeast-1.amazonaws.com
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=...
PORT=3000
S3設定
バケットの作成
[S3] → [バケットを作成] から作成します。
リージョンは.envのAWS_REGIONと合わせてください。
パブリックアクセスのブロックの設定
アップロードした画像をエディタやページ上で表示するため、uploads/配下を公開読み取りにします。
[アクセス許可] → [ブロックパブリックアクセス] → [編集] から、以下のチェックを有効にします。
- 新しいアクセスコントロールリスト (ACL) を介して付与されたバケットとオブジェクトへのパブリックアクセスをブロックする
- 任意のアクセスコントロールリスト (ACL) を介して付与されたバケットとオブジェクトへのパブリックアクセスをブロックする
以下のようになっていればOKです!
バケットポリシーの設定
[アクセス許可] → [バケットポリシー] → [編集] から、以下を貼り付けます。
※{bucket-name}はバケット名を指定します
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadUploads",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::{bucket-name}/uploads/*"
}
]
}
CORSの設定
ブラウザからS3へ直接PUTするので、CORSの設定が必要です。
[アクセス許可] → [Cross-Origin Resource Sharing (CORS)] → [編集] から、以下を貼り付けます。
[
{
"AllowedOrigins": ["http://localhost:3000"],
"AllowedMethods": ["PUT", "GET"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]
プログラム
まずは、必要なパッケージをインストールします。
npm i express @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
{
"name": "summernote-s3-upload-sample",
"version": "1.0.0",
"type": "module",
"scripts": {
"start": "node --env-file=.env server.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0",
"express": "^4.19.2"
}
}
npm startで起動し、http://localhost:3000を開くとエディタが表示されます。
アップロード
サーバー側(署名付きURLの発行)
import express from "express";
import crypto from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const {
AWS_REGION,
S3_BUCKET,
PUBLIC_BASE_URL,
PORT = 3000,
} = process.env;
if (!AWS_REGION || !S3_BUCKET || !PUBLIC_BASE_URL) {
console.error("AWS_REGION / S3_BUCKET / PUBLIC_BASE_URL を .env に設定してください");
process.exit(1);
}
const s3 = new S3Client({
region: AWS_REGION,
// SDK v3.729以降は署名付きURLに空データのチェックサムが付き、ブラウザからのPUTが403になるため無効化
requestChecksumCalculation: "WHEN_REQUIRED",
responseChecksumValidation: "WHEN_REQUIRED",
});
// 許可する画像形式と拡張子
const ALLOWED_TYPES = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
};
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const KEY_PREFIX = "uploads/";
const app = express();
app.use(express.json());
app.use(express.static(path.join(__dirname, "public")));
/**
* 署名付きPUT URLを発行する
* body: { contentType: string, size: number }
* res : { uploadUrl, publicUrl, key }
*/
app.post("/api/upload-url", async (req, res) => {
try {
const { contentType, size } = req.body ?? {};
const ext = ALLOWED_TYPES[contentType];
if (!ext) return res.status(400).json({ error: "対応していない画像形式です" });
if (typeof size !== "number" || size <= 0 || size > MAX_SIZE) {
return res.status(400).json({ error: "ファイルサイズは5MB以下にしてください" });
}
const key = `${KEY_PREFIX}${crypto.randomUUID()}.${ext}`;
const command = new PutObjectCommand({
Bucket: S3_BUCKET,
Key: key,
ContentType: contentType,
ContentLength: size, // 署名にサイズを含め、申告と違うサイズのPUTを拒否
CacheControl: "public, max-age=31536000, immutable",
});
const uploadUrl = await getSignedUrl(s3, command, {
expiresIn: 60, // 秒
signableHeaders: new Set(["content-type", "content-length"]),
});
res.json({ uploadUrl, publicUrl: `${PUBLIC_BASE_URL}/${key}`, key });
} catch (err) {
console.error(err);
res.status(500).json({ error: "署名付きURLの発行に失敗しました" });
}
});
ポイントは以下の3つです。
1. ファイル名はサーバーでUUIDを使って生成
ユーザーがアップロードしたファイル名は使わず、uploads/xxxxxxxx-xxxx-....pngのような名前にしています。
日本語のファイル名や、同じ名前のファイルによる上書きを気にしなくて済みます。
2. Content-TypeとContent-Lengthを署名に含める
signableHeadersでcontent-typeとcontent-lengthを署名に含めています。
こうすることで、申告した形式・サイズと違うファイルをPUTしようとすると、S3側で拒否されます。
3. チェックサムの無効化
AWS SDK v3(v3.729以降)では、署名付きURLを発行すると以下のパラメータが自動で付与されます。
&x-amz-checksum-crc32=AAAAAA%3D%3D&x-amz-sdk-checksum-algorithm=CRC32
URLの発行時点では画像がないため空データのチェックサムになり、PUTが403エラーになります。
そのため、requestChecksumCalculation: "WHEN_REQUIRED"を指定して付与されないようにしています。
フロント側(Summernote)
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SummernoteからS3へアップロードする</title>
<link href="https://cdn.jsdelivr.net/npm/summernote@0.9.1/dist/summernote-lite.min.css" rel="stylesheet">
<link href="style.css" rel="stylesheet">
</head>
<body>
<h1>SummernoteからS3へアップロードする</h1>
<div id="editor"></div>
<div id="status"></div>
<button id="save">保存(未実装)</button>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/summernote@0.9.1/dist/summernote-lite.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/summernote@0.9.1/dist/lang/summernote-ja-JP.min.js"></script>
<script src="app.js"></script>
</body>
</html>
const MAX_SIZE = 5 * 1024 * 1024;
const ALLOWED = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
const $status = $('#status');
function setStatus(msg, isError = false) {
$status.text(msg).toggleClass('error', isError);
}
// サーバーで署名付きURLを発行 → S3へ直接PUT → 公開URLを返す
async function uploadToS3(file) {
const res = await fetch('/api/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contentType: file.type, size: file.size }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || '署名付きURLの取得に失敗しました');
// Content-Type は署名時と同じ値にすること(違うと 403 になる)
const put = await fetch(data.uploadUrl, {
method: 'PUT',
headers: { 'Content-Type': file.type },
body: file,
});
if (!put.ok) throw new Error(`S3へのアップロードに失敗しました (${put.status})`);
return data.publicUrl;
}
$('#editor').summernote({
lang: 'ja-JP',
height: 360,
placeholder: '画像をドラッグ&ドロップ、または画像ボタンから挿入できます',
callbacks: {
// これを定義すると、デフォルトの base64 埋め込みが無効になる
onImageUpload: async function (files) {
const $editor = $(this);
for (const file of files) {
if (!ALLOWED.includes(file.type)) {
setStatus(`${file.name}: 対応していない形式です`, true);
continue;
}
if (file.size > MAX_SIZE) {
setStatus(`${file.name}: 5MB以下にしてください`, true);
continue;
}
try {
setStatus(`${file.name} をアップロード中…`);
const url = await uploadToS3(file);
$editor.summernote('insertImage', url, ($img) => {
$img.attr('alt', file.name);
$img.css('max-width', '100%');
});
setStatus(`${file.name} をアップロードしました`);
} catch (e) {
console.error(e);
setStatus(`${file.name}: ${e.message}`, true);
}
}
},
},
});
callbacks.onImageUploadを定義すると、Summernoteのデフォルト動作(base64埋め込み)が無効になり、自前のアップロード処理に置き換わります。
アップロードに成功すると、本文のHTMLには以下のようにS3のURLだけが入ります。
<p><img src="https://{bucket-name}.s3.ap-northeast-1.amazonaws.com/uploads/xxxx.png" alt="sample.png" style="max-width: 100%;"></p>
おわりに
今回は、Summernoteで挿入した画像を署名付きURLでS3に直接アップロードする仕組みを作りました。
ちょっとした1つの機能ですが結構大掛かりになっちゃいました。今後AWS関連の記事を増やしていけたらと思います。
コードはGitHubにコミットしてあるので参考程度にどうぞ!削除機能も実装しています。
ここまで読んでいただきありがとうございました。


