4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Summernoteで挿入した画像を署名付きURLでS3に直接アップロードする

4
Posted at

はじめに

WYSIWYGエディタの Summernote は、画像を挿入するとデフォルトでは base64に変換してHTMLに直接埋め込みます。

base64のままだと画像がテキストとして本文に含まれてしまうため、画像ファイルのままAWSのストレージであるS3に保存したいと思います。

今回は、Summernoteで挿入した画像を S3の署名付きURLを使ってブラウザから直接アップロードする仕組み を作っていきます。

処理フロー

処理の流れは以下のようになっています。

  1. Summernoteに画像をドラッグ
  2. 署名付きURLを依頼
  3. 署名付きURLを返す
  4. S3に画像を格納
  5. Summernoteに公開URLのを挿入

image.png

ポイントとしては、ブラウザから直接S3にアップロードするので、画像データがサーバーを経由しません。
また、AWSの認証情報はサーバー側だけで持つので、フロントに漏れることもありません。

完成イメージ

こんな感じで画像をドラッグ&ドロップするだけでS3にアップロードできます。

Animation1.gif

環境

項目 バージョン
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}はバケット名を指定します

.env
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です!

image.png

バケットポリシーの設定

[アクセス許可] → [バケットポリシー] → [編集] から、以下を貼り付けます。

※{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
package.json
{
  "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の発行)

server.js
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)

public/index.html
<!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>
public/app.js
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にコミットしてあるので参考程度にどうぞ!削除機能も実装しています。

ここまで読んでいただきありがとうございました。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?