AI SaaS Template Docs
AI SaaS Template Docs
IntroductionQuick StartArchitecture
Vercel DeploymentDocker Deployment
ConfigurationProject StructureDatabase GuideAPI Guide
AuthenticationFile ManagementPayment & Billing
Theme Customization
Features

File Management

AI SaaS Template 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'))"

Authentication

Previous Page

Payment & Billing

Next Page

On this page

OverviewCloud Storage ConfigurationCloudflare R2 SetupAWS S3 SetupR2 Client ConfigurationDatabase SchemaFile SchemaFile ServiceCore File ServiceFile Upload ComponentsFile Upload ComponentFile Manager ComponentServer ActionsFile ActionsFile SharingShare ManagementImage ProcessingImage OptimizationTesting File ManagementUnit TestsIntegration TestsSecurity Best PracticesFile ValidationTroubleshootingCommon IssuesDebug Commands