mcpField
F
file-upload

file-upload

github.com/tushaarmehtaa開発1244 回インストール2 週間前に更新
リポジトリ

file-upload について

署名付きURLを使い、クライアントからストレージへ直接ファイルを送るアップロード機能の導入を支援するスキルです。プロジェクトを調べてストレージを選び、設定、実装、検証を案内します。

主な機能

  1. 01プロジェクト内のストレージ依存関係を検出
  2. 02Vercel Blob、R2、S3の設定を案内
  3. 03署名付きURLによる直接アップロード
  4. 04サーバー側の形式・サイズ検証を説明
  5. 05アップロード検証チェックリストを提供

活用シーン

  1. 01アプリサーバーを介さないファイル送信の追加
  2. 02ブラウザーからR2やS3へのアップロード設定
  3. 03アップロードルートの検証と安全要件の確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/tushaarmehtaa/tushar-skills --skill file-upload

インストール後、Claude Code で/file-uploadを入力して読み込まれたか確認します。

詳細

署名付き URL を使ってファイルアップロードを接続します。クライアントがストレージに直接アップロードするため、サーバーがデータに触れることはありません。まずプロジェクトを読み取り、適切なストレージプロバイダーを選びます。

ファイルアップロードが気づかないうちに失敗する4つの原因

  1. バケットで CORS が設定されていない。 ブラウザーは R2/S3 に直接 PUT リクエストを送信します。オリジンを許可する CORS ルールがないと、すべてのアップロードが CORS エラーで失敗します。Vercel Blob では自動的に処理されますが、R2 と S3 では手動で設定する必要があります。
  2. クライアント側だけで MIME タイプを検証している。 <input> の accept 属性は見かけだけの制限であり、ユーザーは回避できます。クライアント側だけでなく、署名付き URL の生成時に必ずサーバー側で MIME タイプを検証してください。
  3. 署名付き URL を生成する前にファイルサイズを確認していない。 署名付き URL を生成した後にユーザーが 500MB のファイルをアップロードしても、成功してしまいます。S3/R2 の署名付き URL に ContentLengthRange 条件を設定するか、最初のリクエストで Content-Length を確認してください。
  4. 署名付き URL のパスを恒久的なファイル URL として保存している。 署名付き URL には有効期限があります。署名付き URL ではなく、恒久的な公開 URL(または独自のプロキシルート)を保存してください。

フェーズ 1:プロジェクトを検出する

cat package.json | grep -E "@vercel/blob|@aws-sdk|@cloudflare"
  • @vercel/blob → Vercel Blob(Vercel でホストするアプリに最も簡単です)
  • @aws-sdk/client-s3 → AWS S3
  • @cloudflare/workers-types または Wrangler → Cloudflare R2
  • どれもない → ユーザーに確認します(Vercel を利用している場合は、デフォルトで Vercel Blob を選択します)

フェーズ 2:ユーザーに確認する

I'll wire file uploads for your [framework] app.

Quick decisions:

1. Storage provider?
   a) Vercel Blob — simplest, no CORS config needed (default if on Vercel)
   b) Cloudflare R2 — S3-compatible, free egress
   c) AWS S3 — most flexible

2. What file types? (e.g., "images only", "PDF and images", "any")

3. Max file size? (default: 10MB for images, 50MB for documents)

4. Public or private files?
   a) Public — direct URL access, no auth required (avatars, product images)
   b) Private — serve through your API with auth check (user documents, invoices)

フェーズ 3:プロバイダーをセットアップする

オプション A:Vercel Blob(Vercel アプリにおすすめ)
npm install @vercel/blob

.env.example に追加します:

BLOB_READ_WRITE_TOKEN=

Vercel ダッシュボード → Storage → Blob → ストア → Settings から取得します。

オプション B:Cloudflare R2
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner

.env.example に追加します:

R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=
R2_PUBLIC_URL=        # your r2.dev URL or custom domain

Cloudflare ダッシュボード → R2 → バケット → Settings → CORS で CORS を設定します:

[{
  "AllowedOrigins": ["https://yoursite.com", "http://localhost:3000"],
  "AllowedMethods": ["PUT", "GET"],
  "AllowedHeaders": ["Content-Type", "Content-Length"],
  "MaxAgeSeconds": 3600
}]
オプション C:AWS S3

R2 と同じパッケージを使用します。次を追加します:

AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_REGION=
AWS_BUCKET_NAME=

フェーズ 4:アップロード API ルート

サーバー側で署名付き URL を生成します。検証はクライアント側ではなく、ここで行います。

Vercel Blob:

// app/api/upload/route.ts
import { handleUpload, type HandleUploadBody } from '@vercel/blob/client';
import { NextResponse } from 'next/server';

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10MB

export async function POST(req: Request): Promise<NextResponse> {
  const body = (await req.json()) as HandleUploadBody;

  try {
    const jsonResponse = await handleUpload({
      body,
      request: req,
      onBeforeGenerateToken: async (pathname, clientPayload) => {
        // Auth check
        const user = await getAuthUser(req);
        if (!user) throw new Error('Unauthorized');

        return {
          allowedContentTypes: ALLOWED_TYPES,
          maximumSizeInBytes: MAX_SIZE_BYTES,
          tokenPayload: JSON.stringify({ userId: user.id }),
        };
      },
      onUploadCompleted: async ({ blob, tokenPayload }) => {
        const { userId } = JSON.parse(tokenPayload ?? '{}');
        // Store blob.url in your database
        await db.update(users).set({ avatarUrl: blob.url }).where(eq(users.id, userId));
      },
    });

    return NextResponse.json(jsonResponse);
  } catch (error) {
    return NextResponse.json({ error: (error as Error).message }, { status: 400 });
  }
}

R2 / S3(署名付き URL 方式):

// app/api/upload/route.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { NextResponse } from 'next/server';
import { randomUUID } from 'crypto';

const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf'];
const MAX_SIZE_BYTES = 10 * 1024 * 1024;

const s3 = new S3Client({
  region: 'auto',
  endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
  credentials: {
    accessKeyId: process.env.R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
  },
});

export async function POST(req: Request) {
  const user = await getAuthUser(req);
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

  const { filename, contentType, size } = await req.json();

  // Validate server-side
  if (!ALLOWED_TYPES.includes(contentType)) {
    return NextResponse.json({ error: 'File type not allowed' }, { status: 400 });
  }
  if (size > MAX_SIZE_BYTES) {
    return NextResponse.json({ error: 'File too large (max 10MB)' }, { status: 400 });
  }

  const key = `uploads/${user.id}/${randomUUID()}-${filename}`;

  const presignedUrl = await getSignedUrl(
    s3,
    new PutObjectCommand({
      Bucket: process.env.R2_BUCKET_NAME!,
      Key: key,
      ContentType: contentType,
    }),
    { expiresIn: 300 } // 5 minutes
  );

  const publicUrl = `${process.env.R2_PUBLIC_URL}/${key}`;

  return NextResponse.json({ presignedUrl, publicUrl, key });
}

フェーズ 5:アップロードコンポーネント

// components/file-upload.tsx
'use client';
import { useState, useRef } from 'react';

interface UploadState {
  status: 'idle' | 'uploading' | 'success' | 'error';
  progress: number;
  url: string | null;
  error: string | null;
}

export function FileUpload({
  onUploadComplete,
  accept = 'image/*',
  maxSizeMB = 10,
}: {
  onUploadComplete?: (url: string) => void;
  accept?: string;
  maxSizeMB?: number;
}) {
  const [state, setState] = useState<UploadState>({
    status: 'idle', progress: 0, url: null, error: null,
  });
  const inputRef = useRef<HTMLInputElement>(null);

  async function handleFile(file: File) {
    // Client-side size check (real check is server-side)
    if (file.size > maxSizeMB * 1024 * 1024) {
      setState({ status: 'error', progress: 0, url: null, error: `Max size is ${maxSizeMB}MB` });
      return;
    }

    setState({ status: 'uploading', progress: 0, url: null, error: null });

    // Get presigned URL
    const res = await fetch('/api/upload', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ filename: file.name, contentType: file.type, size: file.size }),
    });

    if (!res.ok) {
      const { error } = await res.json();
      setState({ status: 'error', progress: 0, url: null, error });
      return;
    }

    const { presignedUrl, publicUrl } = await res.json();

    // Upload directly to storage with progress
    await new Promise<void>((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.upload.onprogress = (e) => {
        if (e.lengthComputable) {
          setState((s) => ({ ...s, progress: Math.round((e.loaded / e.total) * 100) }));
        }
      };
      xhr.onload = () => (xhr.status === 200 ? resolve() : reject(new Error('Upload failed')));
      xhr.onerror = () => reject(new Error('Network error'));
      xhr.open('PUT', presignedUrl);
      xhr.setRequestHeader('Content-Type', file.type);
      xhr.send(file);
    });

    setState({ status: 'success', progress: 100, url: publicUrl, error: null });
    onUploadComplete?.(publicUrl);
  }

  return (
    <div>
      <input
        ref={inputRef}
        type="file"
        accept={accept}
        className="hidden"
        onChange={(e) => e.target.files?.[0] && handleFile(e.target.files[0])}
      />
      <button type="button" onClick={() => inputRef.current?.click()} disabled={state.status === 'uploading'}>
        {state.status === 'uploading' ? `uploading ${state.progress}%` : 'upload file'}
      </button>
      {state.status === 'uploading' && (
        <div style={{ width: `${state.progress}%`, height: 4, background: '#000' }} />
      )}
      {state.status === 'success' && state.url && (
        <img src={state.url} alt="uploaded" style={{ maxWidth: 200 }} />
      )}
      {state.status === 'error' && <p>{state.error}</p>}
    </div>
  );
}

検証

[ ] File type validation happens in the API route (not just the <input accept> attribute)
[ ] File size check happens in the API route before generating presigned URL
[ ] Auth check happens before generating presigned URL
[ ] CORS configured on R2/S3 bucket (allow your domain + PUT method)
[ ] Presigned URL is not stored — only the permanent public URL is saved to DB
[ ] Presigned URL expires in ≤ 5 minutes
[ ] Upload progress shown to user (not just a spinner)
[ ] Error state renders with the specific error message
[ ] Test: upload a .exe file — confirm it's rejected server-side
[ ] Test: upload a 50MB file — confirm it's rejected before presigned URL is generated

コメント