mcpField
F
file-uploads

file-uploads

github.com/jmsktm开发1066 次安装40 周前更新
代码仓库

关于 file-uploads

file-uploads 是面向 Next.js 开发者的文件上传实现技能,涵盖文件校验、图像处理,以及 Supabase、Cloudinary 和 AWS S3 的存储方案。

主要功能

  1. 01HTML 输入框与拖放上传
  2. 02Supabase Storage 上传和文件管理
  3. 03Cloudinary 与 AWS S3 集成方案
  4. 04文件校验与私有文件 RLS
  5. 05图像压缩和缩略图生成
  6. 06大型文件分块上传

适用场景

  1. 01添加拖放式上传界面
  2. 02通过 Supabase 存储私有用户文件
  3. 03校验并压缩上传的图像
  4. 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 存储

上传到 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