Better SaaS Docs

File Management

Better SaaS provides a robust file management system with cloud storage integration (Cloudflare R2/AWS S3), secure file uploads, image processing, and comprehensive file operations with proper permission controls.

Overview

The file management system includes:

  • Cloud Storage Integration: Cloudflare R2 and AWS S3 support
  • Secure File Uploads: Drag-and-drop interface with validation
  • Image Processing: Automatic thumbnail generation and optimization
  • File Organization: Folders, tags, and search capabilities
  • Permission Controls: User-based and role-based access control
  • File Sharing: Secure sharing with expiration and access controls
  • Bulk Operations: Upload, download, and delete multiple files
  • Storage Quotas: Subscription-based storage limits

Cloud Storage Configuration

Cloudflare R2 Setup

# Cloudflare R2 Configuration
CLOUDFLARE_R2_ACCESS_KEY_ID=your_access_key_id
CLOUDFLARE_R2_SECRET_ACCESS_KEY=your_secret_access_key
CLOUDFLARE_R2_BUCKET_NAME=your_bucket_name
CLOUDFLARE_R2_ENDPOINT=https://your_account_id.r2.cloudflarestorage.com
CLOUDFLARE_R2_PUBLIC_URL=https://your_domain.com

AWS S3 Setup

# AWS S3 Configuration
AWS_ACCESS_KEY_ID=your_access_key_id
AWS_SECRET_ACCESS_KEY=your_secret_access_key
AWS_BUCKET_NAME=your_bucket_name
AWS_REGION=us-east-1
AWS_BUCKET_URL=https://your_bucket.s3.amazonaws.com

R2 Client Configuration

// src/lib/r2-client.ts
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'

export const r2Client = new S3Client({
  region: 'auto',
  endpoint: process.env.CLOUDFLARE_R2_ENDPOINT!,
  credentials: {
    accessKeyId: process.env.CLOUDFLARE_R2_ACCESS_KEY_ID!,
    secretAccessKey: process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY!,
  },
})

export async function uploadFile(key: string, file: Buffer, contentType: string) {
  const command = new PutObjectCommand({
    Bucket: process.env.CLOUDFLARE_R2_BUCKET_NAME!,
    Key: key,
    Body: file,
    ContentType: contentType,
  })

  await r2Client.send(command)
  return `${process.env.CLOUDFLARE_R2_PUBLIC_URL}/${key}`
}

export async function getSignedDownloadUrl(key: string, expiresIn = 3600) {
  const command = new GetObjectCommand({
    Bucket: process.env.CLOUDFLARE_R2_BUCKET_NAME!,
    Key: key,
  })

  return await getSignedUrl(r2Client, command, { expiresIn })
}

export async function deleteFile(key: string) {
  const command = new DeleteObjectCommand({
    Bucket: process.env.CLOUDFLARE_R2_BUCKET_NAME!,
    Key: key,
  })

  await r2Client.send(command)
}

Database Schema

File Schema

// src/server/db/schema.ts
export const files = pgTable("files", {
  id: text("id").primaryKey(),
  userId: text("userId")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  name: text("name").notNull(),
  originalName: text("originalName").notNull(),
  mimeType: text("mimeType").notNull(),
  size: integer("size").notNull(), // in bytes
  key: text("key").notNull(), // S3/R2 key
  url: text("url").notNull(),
  thumbnailUrl: text("thumbnailUrl"),
  folderId: text("folderId").references(() => folders.id, { onDelete: "set null" }),
  tags: text("tags").array(),
  isPublic: boolean("isPublic").default(false),
  downloadCount: integer("downloadCount").default(0),
  createdAt: timestamp("createdAt", { mode: "date" }).defaultNow().notNull(),
  updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow().notNull(),
})

export const folders = pgTable("folders", {
  id: text("id").primaryKey(),
  userId: text("userId")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  name: text("name").notNull(),
  parentId: text("parentId").references(() => folders.id, { onDelete: "cascade" }),
  isPublic: boolean("isPublic").default(false),
  createdAt: timestamp("createdAt", { mode: "date" }).defaultNow().notNull(),
  updatedAt: timestamp("updatedAt", { mode: "date" }).defaultNow().notNull(),
})

export const fileShares = pgTable("fileShares", {
  id: text("id").primaryKey(),
  fileId: text("fileId")
    .notNull()
    .references(() => files.id, { onDelete: "cascade" }),
  userId: text("userId")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
  sharedWithUserId: text("sharedWithUserId")
    .references(() => users.id, { onDelete: "cascade" }),
  shareToken: text("shareToken").unique(),
  permissions: text("permissions").notNull(), // read, write, delete
  expiresAt: timestamp("expiresAt", { mode: "date" }),
  createdAt: timestamp("createdAt", { mode: "date" }).defaultNow().notNull(),
})

File Service

Core File Service

// src/lib/file-service.ts
import { r2Client, uploadFile, deleteFile } from './r2-client'
import { db } from '@/server/db'
import { files, folders } from '@/server/db/schema'
import { eq, and } from 'drizzle-orm'
import sharp from 'sharp'
import { nanoid } from 'nanoid'

export class FileService {
  private static instance: FileService
  private readonly maxFileSize = 10 * 1024 * 1024 // 10MB
  private readonly allowedMimeTypes = [
    'image/jpeg',
    'image/png',
    'image/gif',
    'image/webp',
    'application/pdf',
    'text/plain',
    'application/msword',
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  ]

  static getInstance(): FileService {
    if (!FileService.instance) {
      FileService.instance = new FileService()
    }
    return FileService.instance
  }

  async uploadFile(
    file: File,
    userId: string,
    folderId?: string
  ): Promise<{ id: string; url: string; thumbnailUrl?: string }> {
    // Validate file
    this.validateFile(file)

    // Generate unique key
    const fileExtension = file.name.split('.').pop()
    const key = `${userId}/${nanoid()}.${fileExtension}`

    // Convert file to buffer
    const buffer = Buffer.from(await file.arrayBuffer())

    // Upload to R2
    const url = await uploadFile(key, buffer, file.type)

    // Generate thumbnail for images
    let thumbnailUrl: string | undefined
    if (file.type.startsWith('image/')) {
      thumbnailUrl = await this.generateThumbnail(buffer, key, file.type)
    }

    // Save to database
    const fileId = nanoid()
    await db.insert(files).values({
      id: fileId,
      userId,
      name: this.generateFileName(file.name),
      originalName: file.name,
      mimeType: file.type,
      size: file.size,
      key,
      url,
      thumbnailUrl,
      folderId,
    })

    return { id: fileId, url, thumbnailUrl }
  }

  private async generateThumbnail(
    buffer: Buffer,
    originalKey: string,
    mimeType: string
  ): Promise<string> {
    const thumbnailBuffer = await sharp(buffer)
      .resize(200, 200, { fit: 'inside', withoutEnlargement: true })
      .jpeg({ quality: 80 })
      .toBuffer()

    const thumbnailKey = `thumbnails/${originalKey.replace(/\.[^/.]+$/, '.jpg')}`
    return await uploadFile(thumbnailKey, thumbnailBuffer, 'image/jpeg')
  }

  private validateFile(file: File): void {
    if (file.size > this.maxFileSize) {
      throw new Error(`File size exceeds ${this.maxFileSize / 1024 / 1024}MB limit`)
    }

    if (!this.allowedMimeTypes.includes(file.type)) {
      throw new Error(`File type ${file.type} is not allowed`)
    }
  }

  private generateFileName(originalName: string): string {
    const timestamp = Date.now()
    const extension = originalName.split('.').pop()
    const nameWithoutExtension = originalName.replace(/\.[^/.]+$/, '')
    return `${nameWithoutExtension}-${timestamp}.${extension}`
  }

  async deleteFile(fileId: string, userId: string): Promise<void> {
    const [file] = await db
      .select()
      .from(files)
      .where(and(eq(files.id, fileId), eq(files.userId, userId)))

    if (!file) {
      throw new Error('File not found')
    }

    // Delete from R2
    await deleteFile(file.key)
    if (file.thumbnailUrl) {
      const thumbnailKey = file.thumbnailUrl.split('/').pop()!
      await deleteFile(`thumbnails/${thumbnailKey}`)
    }

    // Delete from database
    await db.delete(files).where(eq(files.id, fileId))
  }

  async getUserFiles(userId: string, folderId?: string) {
    return await db
      .select()
      .from(files)
      .where(
        and(
          eq(files.userId, userId),
          folderId ? eq(files.folderId, folderId) : eq(files.folderId, null)
        )
      )
      .orderBy(files.createdAt)
  }

  async createFolder(name: string, userId: string, parentId?: string) {
    const folderId = nanoid()
    await db.insert(folders).values({
      id: folderId,
      name,
      userId,
      parentId,
    })
    return folderId
  }

  async getUserFolders(userId: string, parentId?: string) {
    return await db
      .select()
      .from(folders)
      .where(
        and(
          eq(folders.userId, userId),
          parentId ? eq(folders.parentId, parentId) : eq(folders.parentId, null)
        )
      )
      .orderBy(folders.createdAt)
  }
}

File Upload Components

File Upload Component

// src/components/file-manager/file-upload.tsx
"use client"

import { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { Card, CardContent } from '@/components/ui/card'
import { Upload, X, File, Image } from 'lucide-react'
import { uploadFiles } from '@/server/actions/file-actions'

interface FileUploadProps {
  folderId?: string
  onUploadComplete?: (files: any[]) => void
  maxFiles?: number
  maxSize?: number
}

export function FileUpload({ 
  folderId, 
  onUploadComplete, 
  maxFiles = 10, 
  maxSize = 10 * 1024 * 1024 
}: FileUploadProps) {
  const [uploading, setUploading] = useState(false)
  const [uploadProgress, setUploadProgress] = useState(0)
  const [selectedFiles, setSelectedFiles] = useState<File[]>([])

  const onDrop = useCallback((acceptedFiles: File[]) => {
    setSelectedFiles(prev => [...prev, ...acceptedFiles].slice(0, maxFiles))
  }, [maxFiles])

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    maxSize,
    accept: {
      'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'],
      'application/pdf': ['.pdf'],
      'text/plain': ['.txt'],
      'application/msword': ['.doc'],
      'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
    },
  })

  const removeFile = (index: number) => {
    setSelectedFiles(prev => prev.filter((_, i) => i !== index))
  }

  const handleUpload = async () => {
    if (selectedFiles.length === 0) return

    setUploading(true)
    setUploadProgress(0)

    try {
      const formData = new FormData()
      selectedFiles.forEach(file => {
        formData.append('files', file)
      })
      if (folderId) {
        formData.append('folderId', folderId)
      }

      const uploadedFiles = await uploadFiles(formData)
      
      onUploadComplete?.(uploadedFiles)
      setSelectedFiles([])
    } catch (error) {
      console.error('Upload failed:', error)
    } finally {
      setUploading(false)
      setUploadProgress(0)
    }
  }

  return (
    <div className="space-y-4">
      <Card>
        <CardContent className="p-6">
          <div
            {...getRootProps()}
            className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${
              isDragActive 
                ? 'border-primary bg-primary/10' 
                : 'border-gray-300 hover:border-gray-400'
            }`}
          >
            <input {...getInputProps()} />
            <Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
            <p className="text-lg font-medium mb-2">
              {isDragActive ? 'Drop files here' : 'Drag and drop files here'}
            </p>
            <p className="text-sm text-gray-500 mb-4">
              or click to select files
            </p>
            <Button type="button" variant="outline">
              Select Files
            </Button>
          </div>
        </CardContent>
      </Card>

      {selectedFiles.length > 0 && (
        <Card>
          <CardContent className="p-4">
            <div className="space-y-2">
              <h3 className="font-medium">Selected Files ({selectedFiles.length})</h3>
              <div className="space-y-2 max-h-40 overflow-y-auto">
                {selectedFiles.map((file, index) => (
                  <div key={index} className="flex items-center justify-between p-2 bg-gray-50 rounded">
                    <div className="flex items-center gap-2">
                      {file.type.startsWith('image/') ? (
                        <Image className="h-4 w-4" />
                      ) : (
                        <File className="h-4 w-4" />
                      )}
                      <span className="text-sm truncate">{file.name}</span>
                      <span className="text-xs text-gray-500">
                        ({(file.size / 1024 / 1024).toFixed(2)} MB)
                      </span>
                    </div>
                    <Button
                      type="button"
                      variant="ghost"
                      size="sm"
                      onClick={() => removeFile(index)}
                    >
                      <X className="h-4 w-4" />
                    </Button>
                  </div>
                ))}
              </div>
            </div>

            {uploading && (
              <div className="mt-4">
                <Progress value={uploadProgress} className="w-full" />
                <p className="text-sm text-gray-500 mt-1">
                  Uploading... {uploadProgress}%
                </p>
              </div>
            )}

            <div className="flex justify-end gap-2 mt-4">
              <Button
                type="button"
                variant="outline"
                onClick={() => setSelectedFiles([])}
                disabled={uploading}
              >
                Clear
              </Button>
              <Button
                type="button"
                onClick={handleUpload}
                disabled={uploading || selectedFiles.length === 0}
              >
                {uploading ? 'Uploading...' : `Upload ${selectedFiles.length} file(s)`}
              </Button>
            </div>
          </CardContent>
        </Card>
      )}
    </div>
  )
}

File Manager Component

// src/components/file-manager/file-manager.tsx
"use client"

import { useState, useEffect } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { 
  DropdownMenu, 
  DropdownMenuContent, 
  DropdownMenuItem, 
  DropdownMenuTrigger 
} from '@/components/ui/dropdown-menu'
import { 
  Folder, 
  File, 
  Image, 
  Download, 
  Trash2, 
  Share, 
  Search,
  Grid,
  List,
  MoreHorizontal
} from 'lucide-react'
import { FileUpload } from './file-upload'
import { FileGrid } from './file-grid'
import { FileTable } from './file-table'
import { getUserFiles, getUserFolders, deleteFile } from '@/server/actions/file-actions'

interface FileManagerProps {
  userId: string
}

export function FileManager({ userId }: FileManagerProps) {
  const [files, setFiles] = useState<any[]>([])
  const [folders, setFolders] = useState<any[]>([])
  const [currentFolder, setCurrentFolder] = useState<string | null>(null)
  const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid')
  const [searchQuery, setSearchQuery] = useState('')
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    loadFiles()
  }, [currentFolder])

  const loadFiles = async () => {
    setLoading(true)
    try {
      const [filesData, foldersData] = await Promise.all([
        getUserFiles(currentFolder),
        getUserFolders(currentFolder)
      ])
      setFiles(filesData)
      setFolders(foldersData)
    } catch (error) {
      console.error('Failed to load files:', error)
    } finally {
      setLoading(false)
    }
  }

  const handleUploadComplete = (uploadedFiles: any[]) => {
    setFiles(prev => [...prev, ...uploadedFiles])
  }

  const handleDeleteFile = async (fileId: string) => {
    try {
      await deleteFile(fileId)
      setFiles(prev => prev.filter(file => file.id !== fileId))
    } catch (error) {
      console.error('Failed to delete file:', error)
    }
  }

  const filteredFiles = files.filter(file =>
    file.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
    file.originalName.toLowerCase().includes(searchQuery.toLowerCase())
  )

  const filteredFolders = folders.filter(folder =>
    folder.name.toLowerCase().includes(searchQuery.toLowerCase())
  )

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold">File Manager</h1>
        <div className="flex items-center gap-2">
          <div className="relative">
            <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
            <Input
              placeholder="Search files..."
              value={searchQuery}
              onChange={(e) => setSearchQuery(e.target.value)}
              className="pl-10 w-64"
            />
          </div>
          <Button
            variant="outline"
            size="sm"
            onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
          >
            {viewMode === 'grid' ? <List className="h-4 w-4" /> : <Grid className="h-4 w-4" />}
          </Button>
        </div>
      </div>

      <FileUpload
        folderId={currentFolder}
        onUploadComplete={handleUploadComplete}
      />

      {loading ? (
        <div className="text-center py-8">Loading files...</div>
      ) : (
        <div className="space-y-4">
          {/* Folders */}
          {filteredFolders.length > 0 && (
            <Card>
              <CardHeader>
                <CardTitle className="flex items-center gap-2">
                  <Folder className="h-5 w-5" />
                  Folders
                </CardTitle>
              </CardHeader>
              <CardContent>
                <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
                  {filteredFolders.map((folder) => (
                    <div
                      key={folder.id}
                      className="flex items-center gap-3 p-3 border rounded-lg hover:bg-gray-50 cursor-pointer"
                      onClick={() => setCurrentFolder(folder.id)}
                    >
                      <Folder className="h-8 w-8 text-blue-500" />
                      <div className="flex-1 min-w-0">
                        <p className="font-medium truncate">{folder.name}</p>
                        <p className="text-sm text-gray-500">
                          {folder.createdAt.toLocaleDateString()}
                        </p>
                      </div>
                    </div>
                  ))}
                </div>
              </CardContent>
            </Card>
          )}

          {/* Files */}
          {filteredFiles.length > 0 ? (
            <Card>
              <CardHeader>
                <CardTitle className="flex items-center gap-2">
                  <File className="h-5 w-5" />
                  Files ({filteredFiles.length})
                </CardTitle>
              </CardHeader>
              <CardContent>
                {viewMode === 'grid' ? (
                  <FileGrid files={filteredFiles} onDelete={handleDeleteFile} />
                ) : (
                  <FileTable files={filteredFiles} onDelete={handleDeleteFile} />
                )}
              </CardContent>
            </Card>
          ) : (
            <Card>
              <CardContent className="text-center py-8">
                <File className="mx-auto h-12 w-12 text-gray-400 mb-4" />
                <p className="text-lg font-medium mb-2">No files found</p>
                <p className="text-gray-500">
                  {searchQuery ? 'Try adjusting your search query' : 'Upload some files to get started'}
                </p>
              </CardContent>
            </Card>
          )}
        </div>
      )}
    </div>
  )
}

Server Actions

File Actions

// src/server/actions/file-actions.ts
"use server"

import { auth } from "@/lib/auth/auth"
import { headers } from "next/headers"
import { FileService } from "@/lib/file-service"
import { db } from "@/server/db"
import { files, folders } from "@/server/db/schema"
import { eq, and } from "drizzle-orm"
import { revalidatePath } from "next/cache"

export async function uploadFiles(formData: FormData) {
  const session = await auth.api.getSession({
    headers: headers(),
  })

  if (!session?.user) {
    throw new Error("Unauthorized")
  }

  const fileService = FileService.getInstance()
  const uploadedFiles = []
  const files = formData.getAll('files') as File[]
  const folderId = formData.get('folderId') as string | undefined

  for (const file of files) {
    try {
      const result = await fileService.uploadFile(file, session.user.id, folderId)
      uploadedFiles.push(result)
    } catch (error) {
      console.error(`Failed to upload ${file.name}:`, error)
    }
  }

  revalidatePath('/dashboard/files')
  return uploadedFiles
}

export async function getUserFiles(folderId?: string) {
  const session = await auth.api.getSession({
    headers: headers(),
  })

  if (!session?.user) {
    throw new Error("Unauthorized")
  }

  const fileService = FileService.getInstance()
  return await fileService.getUserFiles(session.user.id, folderId)
}

export async function getUserFolders(parentId?: string) {
  const session = await auth.api.getSession({
    headers: headers(),
  })

  if (!session?.user) {
    throw new Error("Unauthorized")
  }

  const fileService = FileService.getInstance()
  return await fileService.getUserFolders(session.user.id, parentId)
}

export async function deleteFile(fileId: string) {
  const session = await auth.api.getSession({
    headers: headers(),
  })

  if (!session?.user) {
    throw new Error("Unauthorized")
  }

  const fileService = FileService.getInstance()
  await fileService.deleteFile(fileId, session.user.id)

  revalidatePath('/dashboard/files')
}

export async function createFolder(name: string, parentId?: string) {
  const session = await auth.api.getSession({
    headers: headers(),
  })

  if (!session?.user) {
    throw new Error("Unauthorized")
  }

  const fileService = FileService.getInstance()
  const folderId = await fileService.createFolder(name, session.user.id, parentId)

  revalidatePath('/dashboard/files')
  return folderId
}

File Sharing

Share Management

// src/server/actions/share-actions.ts
"use server"

import { auth } from "@/lib/auth/auth"
import { headers } from "next/headers"
import { db } from "@/server/db"
import { fileShares, files } from "@/server/db/schema"
import { eq, and } from "drizzle-orm"
import { nanoid } from "nanoid"

export async function shareFile(
  fileId: string,
  permissions: 'read' | 'write' | 'delete',
  expiresAt?: Date
) {
  const session = await auth.api.getSession({
    headers: headers(),
  })

  if (!session?.user) {
    throw new Error("Unauthorized")
  }

  // Verify file ownership
  const [file] = await db
    .select()
    .from(files)
    .where(and(eq(files.id, fileId), eq(files.userId, session.user.id)))

  if (!file) {
    throw new Error("File not found")
  }

  const shareToken = nanoid(32)
  const shareId = nanoid()

  await db.insert(fileShares).values({
    id: shareId,
    fileId,
    userId: session.user.id,
    shareToken,
    permissions,
    expiresAt,
  })

  return {
    shareId,
    shareToken,
    shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/share/${shareToken}`,
  }
}

export async function getSharedFile(shareToken: string) {
  const [share] = await db
    .select({
      file: files,
      share: fileShares,
    })
    .from(fileShares)
    .innerJoin(files, eq(fileShares.fileId, files.id))
    .where(eq(fileShares.shareToken, shareToken))

  if (!share) {
    throw new Error("Share not found")
  }

  // Check if share has expired
  if (share.share.expiresAt && share.share.expiresAt < new Date()) {
    throw new Error("Share has expired")
  }

  return share
}

Image Processing

Image Optimization

// src/lib/image-processing.ts
import sharp from 'sharp'

export class ImageProcessor {
  static async optimizeImage(
    buffer: Buffer,
    options: {
      width?: number
      height?: number
      quality?: number
      format?: 'jpeg' | 'png' | 'webp'
    } = {}
  ): Promise<Buffer> {
    const {
      width = 1920,
      height = 1080,
      quality = 80,
      format = 'jpeg'
    } = options

    let processor = sharp(buffer)
      .resize(width, height, { 
        fit: 'inside', 
        withoutEnlargement: true 
      })

    switch (format) {
      case 'jpeg':
        processor = processor.jpeg({ quality })
        break
      case 'png':
        processor = processor.png({ quality })
        break
      case 'webp':
        processor = processor.webp({ quality })
        break
    }

    return await processor.toBuffer()
  }

  static async generateThumbnail(
    buffer: Buffer,
    size: number = 200
  ): Promise<Buffer> {
    return await sharp(buffer)
      .resize(size, size, { fit: 'cover' })
      .jpeg({ quality: 80 })
      .toBuffer()
  }

  static async getImageMetadata(buffer: Buffer) {
    const metadata = await sharp(buffer).metadata()
    return {
      width: metadata.width,
      height: metadata.height,
      format: metadata.format,
      size: metadata.size,
      hasAlpha: metadata.hasAlpha,
    }
  }
}

Testing File Management

Unit Tests

// src/lib/file-service.test.ts
import { describe, it, expect, vi } from 'vitest'
import { FileService } from './file-service'

describe('FileService', () => {
  const fileService = FileService.getInstance()

  it('should validate file size', () => {
    const mockFile = new File([''], 'test.txt', { 
      type: 'text/plain' 
    })
    
    // Mock file size to exceed limit
    Object.defineProperty(mockFile, 'size', { value: 20 * 1024 * 1024 })

    expect(() => fileService.validateFile(mockFile)).toThrow()
  })

  it('should validate file type', () => {
    const mockFile = new File([''], 'test.exe', { 
      type: 'application/exe' 
    })

    expect(() => fileService.validateFile(mockFile)).toThrow()
  })

  it('should generate unique file names', () => {
    const name1 = fileService.generateFileName('test.txt')
    const name2 = fileService.generateFileName('test.txt')
    
    expect(name1).not.toBe(name2)
    expect(name1).toMatch(/test-\d+\.txt/)
  })
})

Integration Tests

// tests/integration/file-upload.test.ts
import { describe, it, expect } from 'vitest'
import { testClient } from '../utils/test-client'

describe('File Upload API', () => {
  it('should upload file successfully', async () => {
    const formData = new FormData()
    const file = new File(['test content'], 'test.txt', { type: 'text/plain' })
    formData.append('files', file)

    const response = await testClient.post('/api/files/upload', formData)
    
    expect(response.status).toBe(200)
    expect(response.data).toHaveProperty('id')
    expect(response.data).toHaveProperty('url')
  })

  it('should reject invalid file types', async () => {
    const formData = new FormData()
    const file = new File(['test'], 'test.exe', { type: 'application/exe' })
    formData.append('files', file)

    const response = await testClient.post('/api/files/upload', formData)
    
    expect(response.status).toBe(400)
  })
})

Security Best Practices

File Validation

// src/lib/file-validation.ts
import { createHash } from 'crypto'

export class FileValidator {
  private static readonly ALLOWED_EXTENSIONS = [
    'jpg', 'jpeg', 'png', 'gif', 'webp',
    'pdf', 'txt', 'doc', 'docx'
  ]

  private static readonly MAGIC_NUMBERS = {
    'image/jpeg': [0xFF, 0xD8, 0xFF],
    'image/png': [0x89, 0x50, 0x4E, 0x47],
    'application/pdf': [0x25, 0x50, 0x44, 0x46],
  }

  static validateFileType(file: File, buffer: Buffer): boolean {
    const extension = file.name.split('.').pop()?.toLowerCase()
    
    if (!extension || !this.ALLOWED_EXTENSIONS.includes(extension)) {
      return false
    }

    // Check magic numbers
    const magicNumbers = this.MAGIC_NUMBERS[file.type as keyof typeof this.MAGIC_NUMBERS]
    if (magicNumbers) {
      const fileHeader = Array.from(buffer.slice(0, magicNumbers.length))
      return magicNumbers.every((byte, index) => byte === fileHeader[index])
    }

    return true
  }

  static generateFileHash(buffer: Buffer): string {
    return createHash('sha256').update(buffer).digest('hex')
  }

  static sanitizeFileName(fileName: string): string {
    return fileName
      .replace(/[^a-zA-Z0-9.-]/g, '_')
      .replace(/_{2,}/g, '_')
      .substring(0, 255)
  }
}

Troubleshooting

Common Issues

  1. File Upload Fails

    • Check file size and type restrictions
    • Verify R2/S3 credentials and permissions
    • Check network connectivity
  2. Thumbnails Not Generated

    • Verify Sharp installation
    • Check image format support
    • Review error logs
  3. Storage Quota Exceeded

    • Implement quota checking
    • Clean up old files
    • Upgrade subscription plan

Debug Commands

# Check file permissions
ls -la uploads/

# Test R2 connection
aws s3 ls s3://your-bucket --endpoint-url=https://your-account.r2.cloudflarestorage.com

# Check Sharp installation
node -e "console.log(require('sharp'))"