file-uploads について
file-uploads は、Next.js アプリでファイルアップロードを実装する開発者向けスキルです。ファイル検証や画像処理に加え、Supabase、Cloudinary、AWS S3 のストレージ手法を扱います。
主な機能
- 01HTML入力とドラッグ&ドロップによるアップロード
- 02Supabase Storageへのアップロードとファイル管理
- 03CloudinaryとAWS S3の連携パターン
- 04ファイル検証と非公開ファイル向けRLS
- 05画像圧縮とサムネイル生成
- 06大容量ファイルのチャンクアップロード
活用シーン
- 01ドラッグ&ドロップ式アップロード画面の追加
- 02Supabaseでユーザーファイルを非公開保存
- 03アップロード画像の検証と圧縮
- 04大容量ファイルの分割アップロード
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ jmsktm/ claude-settings --skill file-uploadsインストール後、Claude Code で/file-uploadsを入力して読み込まれたか確認します。
詳細
ファイルアップロードスキル
概要
このスキルは、Next.js アプリケーションで安全かつ効率的なファイルアップロードを実装するのに役立ちます。基本的なファイル処理からクラウドストレージとの連携まで、堅牢なファイル管理に必要なすべてを網羅しています。
ファイルアップロードの基本
HTML ファイル入力
'use client'
import { useState } from 'react'
export function FileUpload() {
const [file, setFile] = useState<File | null>(null)
const [uploading, setUploading] = useState(false)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setFile(e.target.files[0])
}
}
const handleUpload = async () => {
if (!file) return
setUploading(true)
const formData = new FormData()
formData.append('file', file)
try {
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
const data = await response.json()
console.log('Uploaded:', data.url)
} catch (error) {
console.error('Upload failed:', error)
} finally {
setUploading(false)
}
}
return (
<div>
<input
type="file"
onChange={handleFileChange}
accept="image/*"
/>
<button onClick={handleUpload} disabled={!file || uploading}>
{uploading ? 'Uploading...' : 'Upload'}
</button>
</div>
)
}
ドラッグ&ドロップ
'use client'
import { useState, useCallback } from 'react'
export function DragDropUpload() {
const [isDragging, setIsDragging] = useState(false)
const [file, setFile] = useState<File | null>(null)
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const files = e.dataTransfer.files
if (files && files[0]) {
setFile(files[0])
}
}, [])
return (
<div
onDragEnter={handleDragEnter}
onDragOver={(e) => e.preventDefault()}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`
border-2 border-dashed rounded-lg p-8 text-center
${isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300'}
`}
>
{file ? (
<p>{file.name}</p>
) : (
<p>Drag and drop a file here, or click to select</p>
)}
<input
type="file"
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="hidden"
/>
</div>
)
}
Supabase Storage
Supabase へのアップロード
// lib/supabase/storage.ts
import { createClient } from '@/lib/supabase/client'
export async function uploadFile(
file: File,
bucket: string = 'avatars',
path?: string
): Promise<{ url: string | null; error: any }> {
const supabase = createClient()
// Generate unique filename
const fileExt = file.name.split('.').pop()
const fileName = path || `${Math.random()}.${fileExt}`
const filePath = `${fileName}`
// Upload file
const { error: uploadError } = await supabase.storage
.from(bucket)
.upload(filePath, file, {
cacheControl: '3600',
upsert: false,
})
if (uploadError) {
return { url: null, error: uploadError }
}
// Get public URL
const { data } = supabase.storage.from(bucket).getPublicUrl(filePath)
return { url: data.publicUrl, error: null }
}
// Usage in component
const handleUpload = async (file: File) => {
const { url, error } = await uploadFile(file, 'avatars')
if (error) {
console.error('Upload failed:', error)
return
}
console.log('File uploaded:', url)
}
進行状況を表示しながらアップロード
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
export function UploadWithProgress() {
const [progress, setProgress] = useState(0)
const supabase = createClient()
const handleUpload = async (file: File) => {
const fileExt = file.name.split('.').pop()
const fileName = `${Math.random()}.${fileExt}`
// Create upload with progress tracking
const { error } = await supabase.storage
.from('files')
.upload(fileName, file, {
cacheControl: '3600',
onUploadProgress: (progress) => {
const percent = (progress.loaded / progress.total) * 100
setProgress(Math.round(percent))
},
})
if (error) {
console.error('Upload failed:', error)
return
}
setProgress(100)
}
return (
<div>
<input type="file" onChange={(e) => handleUpload(e.target.files![0])} />
{progress > 0 && (
<div className="w-full bg-gray-200 rounded">
<div
className="bg-blue-600 h-2 rounded"
style={{ width: `${progress}%` }}
/>
<p>{progress}%</p>
</div>
)}
</div>
)
}
プライベートファイル(RLS)
-- Enable RLS on storage bucket
CREATE POLICY "Users can upload their own files"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'private-files'
AND (storage.foldername(name))[1] = auth.uid()::text
);
CREATE POLICY "Users can read their own files"
ON storage.objects FOR SELECT
USING (
bucket_id = 'private-files'
AND (storage.foldername(name))[1] = auth.uid()::text
);
// Upload to user's folder
const userId = user.id
const filePath = `${userId}/${file.name}`
await supabase.storage.from('private-files').upload(filePath, file)
// Get signed URL (expires in 1 hour)
const { data } = await supabase.storage
.from('private-files')
.createSignedUrl(filePath, 3600)
画像の最適化
クライアント側での画像圧縮
npm install browser-image-compression
'use client'
import imageCompression from 'browser-image-compression'
export async function compressImage(file: File): Promise<File> {
const options = {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
useWebWorker: true,
}
try {
const compressedFile = await imageCompression(file, options)
return compressedFile
} catch (error) {
console.error('Compression failed:', error)
return file
}
}
// Usage
const handleUpload = async (file: File) => {
const compressed = await compressImage(file)
await uploadFile(compressed)
}
サーバー側での画像処理(Sharp)
npm install sharp
// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server'
import sharp from 'sharp'
export async function POST(request: NextRequest) {
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Process image
const processed = await sharp(buffer)
.resize(1200, 1200, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toBuffer()
// Upload processed image
// ... upload to Supabase/S3
return NextResponse.json({ success: true })
}
サムネイルの生成
import sharp from 'sharp'
export async function generateThumbnail(
buffer: Buffer,
width: number = 300
): Promise<Buffer> {
return await sharp(buffer)
.resize(width, width, {
fit: 'cover',
position: 'center',
})
.jpeg({ quality: 80 })
.toBuffer()
}
// Usage
const handleUpload = async (file: File) => {
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Generate multiple sizes
const [original, thumbnail, medium] = await Promise.all([
uploadBuffer(buffer, 'original'),
uploadBuffer(await generateThumbnail(buffer, 300), 'thumb'),
uploadBuffer(await generateThumbnail(buffer, 800), 'medium'),
])
return { original, thumbnail, medium }
}
Cloudinary との連携
セットアップ
npm install cloudinary
// lib/cloudinary.ts
import { v2 as cloudinary } from 'cloudinary'
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
})
export async function uploadToCloudinary(
file: File,
folder: string = 'uploads'
): Promise<string> {
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
return new Promise((resolve, reject) => {
cloudinary.uploader
.upload_stream(
{
folder,
resource_type: 'auto',
},
(error, result) => {
if (error) reject(error)
else resolve(result!.secure_url)
}
)
.end(buffer)
})
}
画像の変換
import { getCldImageUrl } from 'next-cloudinary'
// Generate transformed URL
const url = getCldImageUrl({
src: 'sample',
width: 800,
height: 600,
crop: 'fill',
gravity: 'face',
format: 'webp',
quality: 'auto',
})
AWS S3 との連携
セットアップ
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
// lib/s3.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
const s3Client = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
})
export async function uploadToS3(
file: File,
key: string
): Promise<string> {
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const command = new PutObjectCommand({
Bucket: process.env.AWS_BUCKET_NAME!,
Key: key,
Body: buffer,
ContentType: file.type,
})
await s3Client.send(command)
return `https://${process.env.AWS_BUCKET_NAME}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`
}
署名付き URL(直接アップロード)
import { PutObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
export async function getUploadUrl(fileName: string): Promise<string> {
const key = `uploads/${Date.now()}-${fileName}`
const command = new PutObjectCommand({
Bucket: process.env.AWS_BUCKET_NAME!,
Key: key,
})
const url = await getSignedUrl(s3Client, command, { expiresIn: 3600 })
return url
}
// Client-side direct upload
const handleUpload = async (file: File) => {
// Get presigned URL from backend
const { url } = await fetch('/api/upload/get-url', {
method: 'POST',
body: JSON.stringify({ fileName: file.name }),
}).then((r) => r.json())
// Upload directly to S3
await fetch(url, {
method: 'PUT',
body: file,
headers: {
'Content-Type': file.type,
},
})
}
ファイルの検証
ファイルタイプの検証
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
const MAX_SIZE = 5 * 1024 * 1024 // 5MB
export function validateFile(file: File): { valid: boolean; error?: string } {
if (!ALLOWED_TYPES.includes(file.type)) {
return {
valid: false,
error: 'Invalid file type. Only JPEG, PNG, and WebP are allowed.',
}
}
if (file.size > MAX_SIZE) {
return {
valid: false,
error: 'File too large. Maximum size is 5MB.',
}
}
return { valid: true }
}
// Usage
const handleFileSelect = (file: File) => {
const validation = validateFile(file)
if (!validation.valid) {
alert(validation.error)
return
}
// Proceed with upload
uploadFile(file)
}
サーバー側での検証
// app/api/upload/route.ts
export async function POST(request: NextRequest) {
const formData = await request.formData()
const file = formData.get('file') as File
// Validate file type by magic bytes (more secure than extension)
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const isJPEG = buffer[0] === 0xff && buffer[1] === 0xd8
const isPNG = buffer[0] === 0x89 && buffer[1] === 0x50
if (!isJPEG && !isPNG) {
return NextResponse.json(
{ error: 'Invalid file type' },
{ status: 400 }
)
}
// Proceed with upload
}
複数ファイルのアップロード
複数ファイルのアップロード
'use client'
import { useState } from 'react'
export function MultipleFileUpload() {
const [files, setFiles] = useState<File[]>([])
const [uploading, setUploading] = useState(false)
const handleFilesSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
setFiles(Array.from(e.target.files))
}
}
const handleUploadAll = async () => {
setUploading(true)
try {
const uploadPromises = files.map((file) => uploadFile(file))
const results = await Promise.all(uploadPromises)
console.log('All files uploaded:', results)
} catch (error) {
console.error('Upload failed:', error)
} finally {
setUploading(false)
}
}
return (
<div>
<input
type="file"
multiple
onChange={handleFilesSelect}
accept="image/*"
/>
<p>{files.length} files selected</p>
<button onClick={handleUploadAll} disabled={!files.length || uploading}>
{uploading ? 'Uploading...' : 'Upload All'}
</button>
</div>
)
}
レジューム可能なアップロード(大容量ファイル)
チャンク分割アップロード
const CHUNK_SIZE = 1024 * 1024 // 1MB chunks
export async function uploadLargeFile(file: File) {
const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
const uploadId = crypto.randomUUID()
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE
const end = Math.min(start + CHUNK_SIZE, file.size)
const chunk = file.slice(start, end)
const formData = new FormData()
formData.append('chunk', chunk)
formData.append('uploadId', uploadId)
formData.append('chunkIndex', i.toString())
formData.append('totalChunks', totalChunks.toString())
await fetch('/api/upload/chunk', {
method: 'POST',
body: formData,
})
console.log(`Uploaded chunk ${i + 1}/${totalChunks}`)
}
// Finalize upload
await fetch('/api/upload/finalize', {
method: 'POST',
body: JSON.stringify({ uploadId, fileName: file.name }),
})
}
ファイル管理
ファイルの一覧表示
const { data, error } = await supabase.storage.from('avatars').list('', {
limit: 100,
offset: 0,
sortBy: { column: 'created_at', order: 'desc' },
})
ファイルの削除
const { error } = await supabase.storage
.from('avatars')
.remove(['file1.jpg', 'file2.jpg'])
ファイルの移動/名前変更
const { error } = await supabase.storage
.from('avatars')
.move('old-name.jpg', 'new-name.jpg')
ベストプラクティスのチェックリスト
- ファイルタイプを検証する(クライアントとサーバーの両方)
- ファイルサイズの上限を設定する
- アップロード前に画像を圧縮する
- 画像のサムネイルを生成する
- 一意のファイル名を使用する(衝突を避ける)
- アップロードの進行状況を表示する
- アップロードエラーを適切に処理する
- 直接アップロードには署名付き URL を使用する
- プライベートファイルに RLS を実装する
- 失敗したアップロードをクリーンアップする
- ファイル配信に CDN を使用する
- 画像を自動的に最適化する
- 必要に応じてファイルをマルウェアスキャンする
- アップロードにレート制限を実装する
このスキルを使用する場面
以下の場合にこのスキルを呼び出します。
- ファイルアップロード機能を実装する場合
- クラウドストレージ(Supabase/S3/Cloudinary)と連携する場合
- 画像アップロードを最適化する場合
- アバターのアップロードシステムを作成する場合
- ファイル管理機能を構築する場合
- ドラッグ&ドロップによるアップロードを実装する場合
- 大容量ファイルのアップロードを処理する場合
- サムネイル生成を実装する場合
- アップロードの問題をデバッグする場合
- ファイル検証を設定する場合
コメント
Markdown 対応