S3互換の Cloudflare R2 を使って、Hono で署名付きアップロードを実装してみます。
Cloudflare R2 は S3 互換のストレージなので、aws-sdk をそのまま使うことができます。
署名URLは自分で実装することもできますが、実装を簡単にしたいため、今回は aws-sdk を用いて署名付きアップロードを実装します。
また、Cloudflare R2 は、ローカル環境でエミュレート可能ですが、現時点では署名付きアップロードはサポートされていないようです。
そのため、ローカル環境でも実際の Cloudflare R2 を想定して実装します。ただし、ここはLocalStack や MinIO を用いることでローカルPCで完結することも可能なはずです。
今回のサンプルコードは 📄hono/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"
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
クライアントから、署名付き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')
}
R2バケットを本番用のものに差し替えれば、Cloudflare pages でも動作するはずです。(ただし、認証などは適宜ご用意ください)
コメントを送る
コメントはブログオーナーのみ閲覧できます