pinme-r2 について
pinme-r2は、PinMe Workerで注入済みのプロジェクトR2バケットを使うファイル保存スキルです。ストリーミング、所有者単位のアクセス制御、D1メタデータとの連携を扱います。
主な機能
- 01アップロードとダウンロードのストリーミング
- 02検証済み所有者ごとのキー制限
- 03メディアタイプとRangeの検証
- 04検索用メタデータのD1保存
- 05所有者単位のオブジェクト一覧のページング
活用シーン
- 01Workerへの認証付きファイルアップロードの追加
- 02所有者認可に基づくファイル配信
- 03ファイル本体とD1メタデータの分離保存
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ glitternetwork/ pinme --skill pinme-r2インストール後、Claude Code で/pinme-r2を入力して読み込まれたか確認します。
同梱ファイルagents/openai.yaml
詳細
PinMe Worker R2 ストレージ
PinMe がデプロイされたすべての Worker に env.R2 としてバインドする、プロジェクトスコープの R2 バケットを使用してください。認証情報の作成、バケット名の選択、生成された Wrangler 設定の編集は行わないでください。
ランタイムの契約
PinMe は、作成、保存、更新時に信頼できる Worker メタデータを再構築します。クライアントのメタデータで R2 バインディングを置き換えることはできません。
| バインディング | TypeScript の型 | 使用可能な状態 |
|---|---|---|
DB | D1Database | 常に注入されます |
R2 | R2Bucket | 常に注入されます。現在のプロジェクトのバケットです |
API_KEY | string | 常に注入されます |
LLM_API_KEY | string | 常に注入されます |
BASE_URL | string | 常に注入されます |
WORKER_URL | string | 常に注入されます |
PROJECT_NAME | string | 常に注入されます |
UNIWEB_SECRET などの決済関連バインディングは条件付きで、R2 アクセスとは無関係です。
Worker モジュールで使用するバインディングのみを宣言してください。R2 コードは通常、次のように始まります。
export interface Env {
R2: R2Bucket;
PROJECT_NAME: string;
WORKER_URL: string;
}
同じモジュールで D1 のファイルメタデータも管理する場合は、DB: D1Database も必須フィールドとして宣言してください。
R2 と D1 の使い分け
- ファイル本体、画像、添付ファイル、メディア、エクスポート、その他キーで指定するオブジェクトには R2 を使用します。
- 検索可能なビジネスメタデータ、所有者、関連情報、ステータス、監査フィールドには D1 を使用します。
- 管理対象のファイルでは、本体を R2 に保存し、キーとビジネスメタデータのみを D1 に保存します。
- Worker のローカルファイルシステムの状態を永続化に使用したり、ファイル全体や base64 ペイロードを D1 に保存したりしないでください。
必須のセキュリティワークフロー
アップロード、ダウンロード、メタデータ、削除、一覧表示のすべてのルートで、次の手順を適用してください。
authenticate request
→ authorize the project/user action
→ validate size and media policy
→ generate or normalize a scoped object key
→ call env.R2
→ return a sanitized response
アプリケーションで既に使用している認証を使ってください。以下の例では、ルートが検証済みの ID クレームから取得する、信頼できる userId を受け取ります。信頼できないリクエストボディやクエリパラメーターから取得してはいけません。
オブジェクトキーはサーバー側で制御してください。所有者のプレフィックス配下に不透明な ID を使用することを推奨します。
const FILE_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function ownerPrefix(userId: string): string {
if (!userId) throw new Error('Authenticated user id is required');
return `users/${encodeURIComponent(userId)}/files/`;
}
function objectKey(userId: string, fileId: string): string {
if (!FILE_ID_RE.test(fileId)) throw new Error('Invalid file id');
return `${ownerPrefix(userId)}${fileId}`;
}
クライアントから完全なオブジェクトキーを受け取ってはいけません。空の識別子、. または .. のセグメント、バックスラッシュ、制御文字、および他のユーザーのプレフィックスへのアクセスを試みる入力は拒否してください。
共通ヘルパー
小さなヘルパーと明示的なビジネス上限を使用してください。クライアントが指定したあらゆるメディアタイプを受け入れるのではなく、製品に合わせて許可リストを調整してください。
const MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
const ALLOWED_CONTENT_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'application/pdf',
]);
function json(data: unknown, status = 200): Response {
return Response.json(data, { status });
}
function safeDownloadName(value: string | null): string {
const cleaned = (value || 'download')
.replace(/[\r\n"\\]/g, '_')
.replace(/[\x00-\x1f\x7f]/g, '')
.trim();
return (cleaned || 'download').slice(0, 128);
}
function requestedFileId(request: Request): string | null {
const url = new URL(request.url);
const value = url.pathname.split('/').filter(Boolean).at(-1) || '';
return FILE_ID_RE.test(value) ? value : null;
}
クライアントのファイル名と Content-Type は参考情報であり、内容の証明にはなりません。機密性の高い形式では、マジックバイトを検査するか、オブジェクトを非同期の検証・スキャンワークフローに送り、準備完了としてマークする前に確認してください。
アップロードをストリーミングする
このハンドラーを呼び出す前に認証を必須にしてください。request.body を直接 R2 に渡し、先に arrayBuffer()、text()、json()、formData()、または base64 変換を呼び出さないでください。
async function handleUpload(
request: Request,
env: Env,
userId: string,
): Promise<Response> {
if (!request.body) return json({ error: 'File body is required' }, 400);
const lengthHeader = request.headers.get('content-length');
if (!lengthHeader) return json({ error: 'Content-Length is required' }, 411);
const declaredSize = Number(lengthHeader);
if (!Number.isSafeInteger(declaredSize) || declaredSize < 0) {
return json({ error: 'Invalid Content-Length' }, 400);
}
if (declaredSize > MAX_UPLOAD_BYTES) {
return json({ error: 'File is too large' }, 413);
}
const contentType = (request.headers.get('content-type') || '')
.split(';', 1)[0]
.trim()
.toLowerCase();
if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
return json({ error: 'Unsupported media type' }, 400);
}
const fileId = crypto.randomUUID();
const key = objectKey(userId, fileId);
const filename = safeDownloadName(request.headers.get('x-file-name'));
const object = await env.R2.put(key, request.body, {
httpMetadata: {
contentType,
contentDisposition: `attachment; filename="${filename}"`,
},
customMetadata: { ownerId: userId },
});
if (object === null) return json({ error: 'Upload precondition failed' }, 412);
// Content-Length is only a precheck. Enforce the actual stored size too.
if (object.size > MAX_UPLOAD_BYTES) {
await env.R2.delete(key);
return json({ error: 'File is too large' }, 413);
}
return json({ id: fileId, size: object.size, etag: object.httpEtag }, 201);
}
バケット名や内部のオブジェクトキー構成を返さないでください。不透明なファイル ID を返し、後続のルートで認証済み所有者のプレフィックス配下に解決してください。
ダウンロードをストリーミングする
R2 に渡す前に、単一の Range ヘッダーを検証してください。オブジェクトが存在しない場合、R2 は null を返すことがあります。また、条件付きリクエストが失敗した場合は、本文のないメタデータを返すことがあります。
function validRangeHeader(value: string | null): boolean {
if (!value) return true;
const match = /^bytes=(\d*)-(\d*)$/.exec(value);
return Boolean(match && (match[1] || match[2]));
}
async function handleDownload(
request: Request,
env: Env,
userId: string,
): Promise<Response> {
const fileId = requestedFileId(request);
if (!fileId) return json({ error: 'Invalid file id' }, 400);
if (!validRangeHeader(request.headers.get('range'))) {
return json({ error: 'Invalid Range header' }, 416);
}
const object = await env.R2.get(objectKey(userId, fileId), {
onlyIf: request.headers,
range: request.headers,
});
if (object === null) return json({ error: 'Not found' }, 404);
if (!('body' in object)) return new Response(null, { status: 412 });
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
headers.set('accept-ranges', 'bytes');
if (object.range) {
const { offset, length } = object.range;
headers.set(
'content-range',
`bytes ${offset}-${offset + length - 1}/${object.size}`,
);
headers.set('content-length', String(length));
} else {
headers.set('content-length', String(object.size));
}
return new Response(object.body, {
status: object.range ? 206 : 200,
headers,
});
}
D1 メタデータに基づくルートでは、env.R2.get を呼び出す前に D1 の行の所有者を認可してください。クライアントが指定したパスだけから所有権を推測しないでください。
HEAD でメタデータを読み取る
async function handleHead(
request: Request,
env: Env,
userId: string,
): Promise<Response> {
const fileId = requestedFileId(request);
if (!fileId) return json({ error: 'Invalid file id' }, 400);
const object = await env.R2.head(objectKey(userId, fileId));
if (object === null) return new Response(null, { status: 404 });
const headers = new Headers();
object.writeHttpMetadata(headers);
headers.set('etag', object.httpEtag);
headers.set('content-length', String(object.size));
return new Response(null, { status: 200, headers });
}
サイズ、ETag、アップロード時刻、メタデータのみが必要な場合は、head() を使用してください。メタデータのリクエストに応答するために本文をダウンロードしないでください。
オブジェクトを削除する
async function handleDelete(
request: Request,
env: Env,
userId: string,
): Promise<Response> {
const fileId = requestedFileId(request);
if (!fileId) return json({ error: 'Invalid file id' }, 400);
const key = objectKey(userId, fileId);
const object = await env.R2.head(key);
if (object === null) return json({ error: 'Not found' }, 404);
await env.R2.delete(key);
return new Response(null, { status: 204 });
}
R2 では、1 回の delete([...keys]) 呼び出しで最大 1000 個のキーを削除できます。バッチ削除でも、すべてのキーをサーバー側で特定し、認可する必要があります。
所有者のオブジェクトを一覧表示する
エンドユーザー向けのリクエストでバケット全体を一覧表示しないでください。検証済みの ID からプレフィックスを導出し、カーソルは不透明な値として扱ってください。
async function handleList(
request: Request,
env: Env,
userId: string,
): Promise<Response> {
const cursor = new URL(request.url).searchParams.get('cursor');
if (cursor && cursor.length > 2048) {
return json({ error: 'Invalid cursor' }, 400);
}
const page = await env.R2.list({
prefix: ownerPrefix(userId),
cursor: cursor || undefined,
limit: 100,
include: ['httpMetadata', 'customMetadata'],
});
return json({
objects: page.objects.map((object) => ({
id: object.key.slice(ownerPrefix(userId).length),
size: object.size,
uploaded: object.uploaded.toISOString(),
etag: object.httpEtag,
contentType: object.httpMetadata?.contentType,
})),
nextCursor: page.truncated ? page.cursor : null,
});
}
R2 の list 呼び出しは最大 1000 件のエントリを返します。また、メタデータを含める場合は、指定した上限より少ない件数を返すことがあります。page.truncated が true の場合にのみ続行してください。ページネーションの条件に objects.length === limit を使用しないでください。
ルートとエラーのセマンティクス
ルーターで一度だけ認証し、信頼できる userId を導出してからハンドラーに渡します。サポートされていないメソッドには Allow ヘッダーを返します。
| ステータス | 意味 |
|---|---|
| 400 | 無効なファイル ID、本文、カーソル、またはメディアタイプ |
| 401 | 認証情報がない、または無効 |
| 403 | 認証済みだが、対象オブジェクトへのアクセスが許可されていない |
| 404 | オブジェクトまたは所有者に紐づくメタデータレコードが見つからない |
| 411 | 上限付きのアップロードルートで Content-Length が必要だが、指定されていない |
| 412 | 条件付き R2 操作に失敗 |
| 413 | ビジネス上またはプラットフォーム上のアップロード上限を超過 |
| 416 | 無効、または満たせない Range リクエスト |
| 500 | サニタイズされた内部ストレージエラー |
ストレージ障害はルート境界で捕捉し、機密情報を含まないコンテキストのみをログに記録して、一般的なエラーを返します。プロバイダーの生のエラー、バケット名、認証情報、内部オブジェクトキーは決して返しません。
有効だが満たせない R2 Range エラーは、プロバイダーのエラーテキストを返さずに 416 に変換します。
R2 と D1 を連携させる
R2 と D1 はトランザクションを共有しません。ビジネスメタデータが必要な場合は、明示的な状態遷移を使用します。
insert D1 row with status=pending
→ stream body to R2
→ update D1 row to status=ready
- アップロードに失敗した場合は、保留中の行を削除するか、失敗としてマークします。
- 最終的な D1 更新に失敗した場合は、新しくアップロードされたオブジェクトを削除するか、補償ジョブで処理できる永続的な保留状態を維持します。
- 少なくとも、公開ファイル ID、内部オブジェクトキー、所有者 ID、元の名前、サイズ、MIME タイプ、ステータス、タイムスタンプを保存します。
- ダウンロードと削除では、R2 に触れる前に公開ファイル ID と所有者 ID で行を取得します。
- 明示的な再試行/補償ポリシーを用いて R2 オブジェクトと D1 の行を削除します。2 つの操作がアトミックであるかのように扱ってはいけません。
大容量ファイル
小〜中容量のアップロードには request.body → env.R2.put を使用します。ストリーミングにより Worker のメモリ使用量の増幅を避けられますが、アカウントプランの Cloudflare リクエスト本文サイズ上限を回避できるわけではありません。
オブジェクトがそのリクエスト上限を超える場合、または再開可能なアップロードが明示的な製品要件である場合にのみ、マルチパートを使用します。マルチパート API は次の要件を満たす必要があります。
- 作成、パートのアップロード、完了、再開、中止の各操作をすべて認証する。
- 永続的な状態で、オブジェクトキーとアップロード ID を所有者に紐付ける。
- パート番号、パートサイズ、合計サイズ、申告されたコンテンツタイプを検証する。
- 完了処理を冪等にし、古いアップロードを中止する。
- 信頼できないクライアントから任意のキーやアップロード ID を受け入れない。
デフォルトでは、公開マルチパートコントローラーを生成しないでください。マルチパートの状態管理とセキュリティは、単一のストリーミングアップロードよりもはるかに複雑です。
ローカル開発
- R2 バインディングを追加するために、PinMe が生成した
backend/wrangler.tomlを編集しないでください。 - 絞り込んだ
R2Bucketモックを使って、キー生成、認可、ルーティング、障害処理をユニットテストします。 pinme update-workerまたはpinme saveの後に、実際のメタデータ、Range、条件付きリクエスト、ストリーミングを検証します。- モックはロジックのテストとして扱い、本番環境での R2 の動作を証明するものではないと理解してください。
アンチパターン
| 避けること | 代わりに使用すること |
|---|---|
| 認証のないアップロードルートを公開する | 変更を行う前に毎回認証と認可を行う |
| クライアントから完全なオブジェクトキーを受け入れる | サーバーが導出した所有者プレフィックスの下で、不透明な ID を生成する |
| JSON やクエリパラメーターのユーザー ID を信頼する | 検証済みクレームから ID を導出する |
| 大きな本文を ArrayBuffer や base64 文字列に読み込む | request.body を env.R2.put に直接ストリーミングする |
| ファイル本文や base64 を D1 に保存する | 本文は R2 に、検索可能なメタデータは D1 に保存する |
| バケット全体を一覧表示する | 所有者プレフィックスで絞り込み、ページネーションする |
| 返されたオブジェクト数に基づいてページネーションを終了する | page.truncated を確認し、page.cursor を返す |
| レスポンスメタデータを破棄する | writeHttpMetadata、httpEtag、長さ、Range ヘッダーを適用する |
fs やローカルディレクトリで永続化する | 注入された R2 バインディングを使用する |
| ソース/設定に R2 のキーやシークレットを追加する | env.R2 を使用する。バインディングは PinMe が管理します |
| 生成された Wrangler のバインディング設定を編集する | pinme save または pinme update-worker でデプロイする |