Site cover image
Hono/Cloudflare R2 で署名付きアップロードを実装する

S3互換の Cloudflare R2 を使って、Hono で署名付きアップロードを実装してみます。

Cloudflare R2 は S3 互換のストレージなので、aws-sdk をそのまま使うことができます。

署名URLは自分で実装することもできますが、実装を簡単にしたいため、今回は aws-sdk を用いて署名付きアップロードを実装します。

また、Cloudflare R2 は、ローカル環境でエミュレート可能ですが、現時点では署名付きアップロードはサポートされていないようです。

そのため、ローカル環境でも実際の Cloudflare R2 を想定して実装します。ただし、ここはLocalStack や MinIO を用いることでローカルPCで完結することも可能なはずです。

今回のサンプルコードは 📄Arrow icon of a page linkhono/auth-js を使って Hono/Cloudflare pages で Google認証する で構築した Hono + React の SPA 環境を想定しています。

特に Hono である必要も React である必要もないので、ご自身の環境に合わせて修正してください。

Cloudflare R2 の管理画面から、もしくはコマンドで R2 バケットを作成します。

wrangler r2 bucket create [BUCKET_NAME]

作成できたら、Cloudflare R2 の管理画面にいき、API/Use R2 with APIs の S3 Compatible API からエンドポイントの情報を取得します。

また、API/Manage API tokens から、Token を作成し、Access Key ID と、Secret Access Key の情報を取得します。

API Token は、作成したバケット、Object Read & Write で作成し、TTL や Client IP Address Filtering などは適宜設定してください。

情報が取得できたら、.dev.vars に環境変数として定義します。

CLOUDFLARE_REGION は auto に設定してください。

CLOUDFLARE_REGION = "auto"
CLOUDFLARE_R2_ENDPOINT = "https://xxxxx.r2.cloudflarestorage.com"
CLOUDFLARE_R2_BUCKET_NAME = "xxx-bucket"
CLOUDFLARE_R2_ACCESS_KEY_ID = "xxxxxxx"
CLOUDFLARE_R2_SECRET_ACCESS_KEY = "xxxxxxx"
.dev.vars

R2 バケットの Settings から、CORS Policy を以下のように設定します。port が違う場合は、適宜修正してください。

[
  {
    "AllowedOrigins": [
      "http://localhost:5173"
    ],
    "AllowedMethods": [
      "GET",
      "PUT"
    ],
    "AllowedHeaders": [
      "*"
    ]
  }
]

今回は、aws-sdk を用いて、署名付きアップロードを実装します。Cloudflare R2 は S3 互換のため、aws-sdk をそのまま使うことができます。

pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

署名付きアップロードURL生成には、R2のアクセスキーが必要なため、サーバー側に署名付きアップロードURLを生成して返却するAPIを実装します。(今回は、サンプルコードのため、認証については省略しています。)


import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { type Context, Hono } from 'hono';

type Bindings = {
  CLOUDFLARE_REGION: string;
  CLOUDFLARE_R2_ENDPOINT: string;
  CLOUDFLARE_R2_BUCKET_NAME: string;
  CLOUDFLARE_R2_ACCESS_KEY_ID: string;
  CLOUDFLARE_R2_SECRET_ACCESS_KEY: string;
}

const app = new Hono<{ Bindings: Bindings }>();

const r2Client = (c: Context<{ Bindings: Bindings}>) => {
  return new S3Client({
    region: c.env.CLOUDFLARE_REGION,
    endpoint: c.env.CLOUDFLARE_R2_ENDPOINT,
    credentials: {
      accessKeyId: c.env.CLOUDFLARE_R2_ACCESS_KEY_ID,
      secretAccessKey: c.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY,
    },
  });
}

// 署名付き URL 生成エンドポイント
app.get('/api/get-presigned-url', async (c) => {
  const client = r2Client(c);
  const key = crypto.randomUUID();
  const command = new PutObjectCommand({
    Bucket: c.env.CLOUDFLARE_R2_BUCKET_NAME,
    Key: key,
    ContentType: 'application/octet-stream',
  });

  const signedUrl = await getSignedUrl(client, command, { expiresIn: 3600 });

  return c.json({ url: signedUrl, key: key });
});

app.get('/', (c) => {
  return c.html(
    renderToString(
      <html lang="ja">
        <head>
          <meta charSet="utf-8" />
          <meta content="width=device-width, initial-scale=1" name="viewport" />
          <link rel="stylesheet" href="https://cdn.simplecss.org/simple.min.css" />
          {import.meta.env.PROD ? (
            <script type="module" src="/static/root.js" />
          ) : (
            <script type="module" src="/src/root.tsx" />
          )}
          <body>
            <div id="root" />
          </body>
        </head>
      </html>
    )
  )
})


export default app
index.tsx

クライアントから、署名付きURL生成エンドポイント叩き、実際にアップロードする部分を実装します。

今回は、複数ファイルのアップロードを想定しています。

React を使って実装していますが、適宜環境に合わせて修正してください。

import { useState } from 'react';
import { createRoot } from 'react-dom/client';

function App() {
  const [files, setFiles] = useState<FileList | null>(null);

  const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
    setFiles(event.target.files);
  }
  const handleSubmit = async (event: React.FormEvent) => {
    event.preventDefault();

    if (!files || files.length === 0) {
      console.error('No files selected');
      return;
    }

    try {
      const uploadPromises = Array.from(files).map(async (file) => {
        try {
          const presignedUrlResponse = await fetch('/api/admin/get-presigned-url');

          if (!presignedUrlResponse.ok) {
            throw new Error('Failed to fetch presigned URL');
          }

          const { url, key }: { url: string; key: string } = await presignedUrlResponse.json();

          const uploadResponse: Response = await fetch(url, {
            method: 'PUT',
            body: file,
            headers: {
              'Content-Type': file.type,
            },
          });

          if (uploadResponse.ok) {
            console.log(`File ${file.name} uploaded successfully!`);
          } else {
            throw new Error(`Failed to upload file ${file.name}`);
          }
        } catch (error) {
          console.error(`Error uploading file ${file.name}:`, error);
        }
      });

      // すべてのアップロードが完了するのを待つ
      await Promise.all(uploadPromises);
      console.log('All files uploaded successfully');
    } catch (error) {
      console.error('Error in handleSubmit:', error);
    }
  };

  return (
	  <form onSubmit={handleSubmit}>
	    <input type="file" multiple accept="image/*" onChange={handleFileChange}  />
	    <button type="submit">Upload</button>
	  </form>
  )
}

const domNode = document.getElementById('root')
if (domNode) {
  const root = createRoot(domNode)
  root.render(<App />)
} else {
  console.error('Failed to find the root element')
}
root.tsx

R2バケットを本番用のものに差し替えれば、Cloudflare pages でも動作するはずです。(ただし、認証などは適宜ご用意ください)

Thank you!
Thank you!
URLをコピーしました

コメントを送る

コメントはブログオーナーのみ閲覧できます