Dateispeicher

Datei-Uploads mit Vercel Blob — stufengesteuerter Zugriff, Drag & Drop, Validierung und Datenbank-Tracking

Das Kit enthält ein vollständiges Datei-Upload-System, das auf Vercel Blob basiert. Das System umfasst stufengesteuerten Zugriff (Upload-Berechtigungen basierend auf dem Abonnement-Plan), Drag & Drop mit Fortschrittsanzeige, Dateivalidierung mit Sicherheitsüberprüfungen und Datenbank-Tracking für jede hochgeladene Datei.
Diese Seite behandelt die Einrichtung, den Upload-Ablauf, die Validierung und die Dateiverwaltung. Informationen zum API-Rate-Limiting für Upload-Routen findest du unter Caching & Redis. Zu Sicherheitsdetails siehe Sicherheitsübersicht.

Funktionsweise

Jeder Datei-Upload durchläuft Validierung, Blob-Speicherung und Datenbank-Tracking:
UI-Komponente (DropZoneUpload / StandardFileInput)
    |
    |--- Client-seitige Validierung (Größe, Typ, Erweiterung)
    |--- Zeigt Upload-Fortschritt
    |
    v
API-Route (POST /api/upload)
    |--- 1. Rate-Limit-Prüfung (withRateLimit('upload'))
    |--- 2. Zod-Schema-Validierung (filename, filesize, filetype)
    |--- 3. Dateiname-Bereinigung
    |--- 4. Benutzerauthentifizierung (Clerk)
    |
    v
Storage-Service (vercel-blob.ts)
    |--- Generiert eindeutige Datei-ID
    |--- Erstellt benutzerbezogenen Pfad: users/{userId}/{fileId}/{filename}
    |--- Lädt zu Vercel Blob hoch (öffentlicher Zugriff)
    |
    v
Datenbankdatensatz (Prisma File-Modell)
    |--- Speichert Datei-Metadaten (url, size, contentType)
    |--- Verknüpft Datei mit Benutzer (userId Foreign Key)
    |--- Gibt Dateidatensatz an den Client zurück

Einrichtung

1

Vercel Blob in deinem Projekt aktivieren

Gehe im Vercel-Dashboard zum Storage-Tab deines Projekts und erstelle einen neuen Blob-Store. Vercel Blob ist in allen Vercel-Plänen enthalten (kostenloser Plan: 1 GB Speicher, 1 GB Bandbreite/Monat).
2

Umgebungsvariable setzen

Kopiere den Lese-/Schreib-Token aus den Vercel-Blob-Store-Einstellungen und füge ihn zu apps/boilerplate/.env.local hinzu:
bash
BLOB_READ_WRITE_TOKEN=vercel_blob_rw_your_token_here

Upload-Ablauf

Die Upload-API-Route validiert Parameter mit Zod, authentifiziert den Benutzer über Clerk und streamt die Datei dann zu Vercel Blob:
src/app/api/upload/route.ts — POST Handler (Validation)
export const POST = withRateLimit('upload', async (request: NextRequest) => {
  let uploadResult: Awaited<ReturnType<typeof uploadToBlob>> | null = null
  let userId: string | null = null
  let clerkUserId: string | null = null
  let filename: string | null = null
  let filesize: string | null = null
  let filetype: string | null = null

  try {
    // Get and validate query params
    const { searchParams } = new URL(request.url)
    const rawFilename = searchParams.get('filename')
    const rawFilesize = searchParams.get('filesize')
    const rawFiletype = searchParams.get('filetype')

    // Validate upload parameters using centralized schema
    const validation = uploadFileSchema.safeParse({
      filename: rawFilename,
      filesize: rawFilesize,
      filetype: rawFiletype,
    })

    if (!validation.success) {
      console.error('Upload validation failed:', validation.error.flatten())
      return NextResponse.json(
        {
          error: 'Invalid upload parameters',
          details: validation.error.flatten().fieldErrors,
        },
        { status: 400 }
      )
    }

    // Extract validated and sanitized data
    const validatedData = validation.data
    filename = sanitizeFilename(validatedData.filename)
    filesize = validatedData.filesize.toString()
    filetype = validatedData.filetype
Der Storage-Service generiert einen eindeutigen Dateipfad, der auf den Benutzer begrenzt ist, und lädt über das Vercel Blob SDK hoch:
src/lib/storage/vercel-blob.ts — uploadToBlob
export async function uploadToBlob(
  userId: string,
  file: File | Blob | ReadableStream | ArrayBuffer,
  filename: string,
  fileSize?: number
): Promise<FileUploadResult> {
  const fileId = generateFileId()
  const pathname = generateFilePath(userId, fileId, filename)

  try {
    // Get file extension to determine content type
    const extension = filename.toLowerCase().split('.').pop()
    let contentType = 'application/octet-stream'

    if (extension) {
      const mimeTypes: Record<string, string> = {
        jpg: 'image/jpeg',
        jpeg: 'image/jpeg',
        png: 'image/png',
        gif: 'image/gif',
        webp: 'image/webp',
        pdf: 'application/pdf',
      }
      contentType = mimeTypes[extension] || contentType
    }

    // Upload to Vercel Blob
    const blob = await put(pathname, file, {
      access: 'public',
      contentType,
    })

    // Use provided size or default to 0
    // Note: Vercel Blob doesn't return size in response for streaming uploads
    const size = fileSize || 0

    return {
      id: fileId,
      url: blob.url,
      pathname,
      originalName: filename,
      size, // Size from parameter or 0
      contentType,
    }
  } catch (error) {
    console.error('Failed to upload to Vercel Blob:', error)
    throw new Error('Failed to upload file. Please try again.')
  }
}

Dateivalidierung

Dateien werden sowohl auf dem Client (UI-Komponenten) als auch auf dem Server (API-Route) mit drei Prüfungen validiert:
src/lib/storage/validation.ts — Validation Rules
import type { FileValidationOptions, FileValidationResult } from './types'

// Max file size: 4.5MB (Vercel Blob limit)
export const MAX_FILE_SIZE = 4.5 * 1024 * 1024

// Default allowed file types
export const DEFAULT_ALLOWED_TYPES = [
  'image/jpeg',
  'image/jpg',
  'image/png',
  'image/gif',
  'image/webp',
  'application/pdf',
]

/**
 * Validate a file before upload
 */
export function validateFile(
  file: File | Blob,
  options: FileValidationOptions = {}
): FileValidationResult {
  const { maxSize = MAX_FILE_SIZE, allowedTypes = DEFAULT_ALLOWED_TYPES } =
    options

  // Check file size
  if (file.size > maxSize) {
    const maxSizeMB = (maxSize / (1024 * 1024)).toFixed(1)
    return {
      valid: false,
      error: `File size exceeds maximum of ${maxSizeMB}MB`,
    }
  }

  // Check file type
  if (allowedTypes.length > 0 && !allowedTypes.includes(file.type)) {
    return {
      valid: false,
      error: `File type ${file.type} is not allowed. Allowed types: ${allowedTypes.join(', ')}`,
    }
  }

  // Additional validation for file names if it's a File object
  if ('name' in file) {
    const fileName = file.name

    // Check for suspicious file extensions
    const suspiciousExtensions = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.dll']
    const hasSupiciousExtension = suspiciousExtensions.some((ext) =>
      fileName.toLowerCase().endsWith(ext)
    )

    if (hasSupiciousExtension) {
      return {
        valid: false,
        error: 'This file type is not allowed for security reasons',
      }
    }
  }

  return { valid: true }
}

Validierungsregeln

PrüfungRegelFehlermeldung
DateigrößeMax. 4,5 MB (Vercel Blob Limit)"File size exceeds maximum of 4.5MB"
DateitypNur JPEG, PNG, GIF, WebP, PDF"File type {type} is not allowed"
ErweiterungBlockiert .exe, .bat, .cmd, .sh, .ps1, .dll"This file type is not allowed for security reasons"
DateinameServer-seitige Bereinigung über sanitizeFilename()Nicht zutreffend (wird still bereinigt)

Stufengesteuerter Zugriff

Der Datei-Upload-Zugriff wird durch die Abonnement-Stufe gesteuert. Kostenlose Benutzer können keine Dateien hochladen, höhere Stufen erhalten größere Größenlimits und eine bessere Benutzeroberfläche:
FunktionFreeBasicProEnterprise
Upload-ZugriffNeinJaJaJa
Max. Dateigröße2 MB4,5 MB4,5 MB
Upload-UIStandard-Datei-PickerDrag & DropDrag & Drop
DateitypenBilder, PDFBilder, PDFBilder, PDF
Die TierAwareUpload-Komponente handhabt dies automatisch — sie prüft die Abonnement-Stufe des Benutzers und rendert die passende Upload-Komponente (oder nichts für kostenlose Benutzer).

UI-Komponenten

Das Kit enthält 5 Upload-bezogene Komponenten in apps/boilerplate/src/components/upload/:
KomponenteBeschreibung
DropZoneUploadDrag-&-Drop-Zone mit visuellem Feedback, Dateityp-Icons und Upload-Fortschrittsbalken. Für Pro- und Enterprise-Stufen verfügbar.
StandardFileInputHerkömmliches Datei-Input mit einem gestalteten Button. Wird für die Basic-Stufe verwendet.
FilePreviewZeigt hochgeladene Datei mit Vorschaubild (Bilder) oder Icon (PDFs), Dateiname, Größe und einem Löschen-Button.
TierAwareUploadWrapper, der DropZoneUpload oder StandardFileInput basierend auf der Abonnement-Stufe des Benutzers bedingt rendert. Zeigt kostenlose Benutzern eine Upgrade-Aufforderung.
SettingsUploadSectionVollständiger Upload-Bereich für die Einstellungsseite — kombiniert stufenbewussten Upload mit Dateiliste und -verwaltung.

Verwendungsbeispiel

tsx
import { TierAwareUpload } from '@/components/upload/tier-aware-upload'

export default function SettingsPage() {
  return (
    <TierAwareUpload
      onUploadComplete={(file) => {
        console.log('Uploaded:', file.url)
      }}
    />
  )
}

Dateien verwalten

Hochgeladene Dateien können über den /api/files/[id]-Endpunkt abgerufen und gelöscht werden. Beide Operationen verifizieren die Eigentümerschaft — ein Benutzer kann nur auf seine eigenen Dateien zugreifen:
src/app/api/files/[id]/route.ts — GET (File Details)
export async function GET(request: NextRequest, { params }: Params) {
  try {
    const fileId = params.id

    // Get current user
    let userId: string | null = null

    if (shouldBypassClerk()) {
      userId = 'test-user-id'
    } else {
      const user = await currentUser()
      if (!user) {
        return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
      }

      const dbUser = await prisma.user.findUnique({
        where: { clerkId: user.id },
      })

      if (!dbUser) {
        return NextResponse.json({ error: 'User not found' }, { status: 404 })
      }

      userId = dbUser.id
    }

    // Get file from database
    const file = await prisma.file.findFirst({
      where: {
        id: fileId,
        userId,
      },
    })

    if (!file) {
      return NextResponse.json({ error: 'File not found' }, { status: 404 })
    }

    return NextResponse.json({
      id: file.id,
      url: file.url,
      originalName: file.originalName,
      size: file.size,
      contentType: file.contentType,
      createdAt: file.createdAt,
      updatedAt: file.updatedAt,
    })
  } catch (error) {
    console.error('Get file error:', error)
    return NextResponse.json({ error: 'Failed to get file' }, { status: 500 })
  }
}

DELETE — Datei löschen

Der DELETE-Handler entfernt die Datei sowohl aus Vercel Blob als auch aus der Datenbank. Wenn das Löschen des Blobs fehlschlägt, wird der Datenbankdatensatz trotzdem entfernt, um verwaiste Datensätze zu vermeiden:
src/app/api/files/[id]/route.ts — DELETE
export async function DELETE(request: NextRequest, { params }: Params) {
  try {
    const fileId = params.id

    // Get current user
    let userId: string | null = null

    if (shouldBypassClerk()) {
      userId = 'test-user-id'
    } else {
      const user = await currentUser()
      if (!user) {
        return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
      }

      const dbUser = await prisma.user.findUnique({
        where: { clerkId: user.id },
      })

      if (!dbUser) {
        return NextResponse.json({ error: 'User not found' }, { status: 404 })
      }

      userId = dbUser.id
    }

    // Get file from database
    const file = await prisma.file.findFirst({
      where: {
        id: fileId,
        userId,
      },
    })

    if (!file) {
      return NextResponse.json({ error: 'File not found' }, { status: 404 })
    }

    // Delete from Vercel Blob
    const deleteResult = await deleteFromBlob(file.url)

    if (!deleteResult.success) {
      console.error('Failed to delete from Vercel Blob:', deleteResult.error)
      // Continue with database deletion even if blob deletion fails
      // This prevents orphaned records
    }

    // Delete from database
    await prisma.file.delete({
      where: { id: fileId },
    })

    return NextResponse.json({
      success: true,
      message: 'File deleted successfully',
    })
  } catch (error) {
    console.error('Delete file error:', error)
    return NextResponse.json(
      { error: 'Failed to delete file' },
      { status: 500 }
    )
  }
}

Datenbankschema

Jede hochgeladene Datei wird im File-Modell verfolgt:
File
├── id            # Automatisch generierte UUID
├── userId        # Foreign Key zu User (Eigentümer)
├── url           # Öffentliche Vercel Blob URL
├── pathname      # Speicherpfad: users/{userId}/{fileId}/{filename}
├── originalName  # Ursprünglicher Dateiname (bereinigt)
├── contentType   # MIME-Typ (image/jpeg, application/pdf usw.)
├── size          # Dateigröße in Bytes
├── metadata      # JSON — erweiterbares Metadaten-Feld
├── createdAt     # Upload-Zeitstempel
└── updatedAt     # Letzte Änderung
Dateien sind über den userId-Foreign-Key auf Benutzer begrenzt. Alle Abfragen enthalten einen userId-Filter zur Durchsetzung der Eigentümerschaft.

Sicherheit

Das Upload-System umfasst mehrere Sicherheitsebenen:
EbeneSchutz
Dateiname-BereinigungsanitizeFilename() entfernt Path-Traversal, Sonderzeichen und Null-Bytes
ErweiterungssperrungWeist ausführbare Erweiterungen zurück (.exe, .bat, .cmd, .sh, .ps1, .dll)
MIME-Typ-ValidierungErlaubt nur konfigurierte Content-Types (Bilder, PDF)
GrößenlimitsHartes 4,5-MB-Limit (Vercel Blob Maximum)
Benutzer-EigentümerschaftAlle Dateioperationen verifizieren userId — Benutzer können nur auf ihre eigenen Dateien zugreifen
Rate LimitingUpload-Endpunkt durch withRateLimit('upload') geschützt — 10 Req/Stunde pro Benutzer, 20/Stunde pro IP
Zod-ValidierungServer-seitige Parametervalidierung mit zentralisierten Schemas
Orphan-CleanupWenn das Datenbank-Speichern nach dem Upload fehlschlägt, wird der Blob automatisch gelöscht

Umgebungsvariablen

VariableErforderlichZweck
BLOB_READ_WRITE_TOKENJaVercel Blob Storage Token für Uploads und Löschungen

Wichtige Dateien

DateiZweck
apps/boilerplate/src/lib/storage/vercel-blob.tsBlob-SDK-Wrapper — Upload, Löschen, Auflisten, Speicherstatistiken
apps/boilerplate/src/lib/storage/validation.tsDateivalidierung — Größenlimits, erlaubte Typen, Sicherheitsüberprüfungen
apps/boilerplate/src/lib/storage/file-utils.tsHilfsfunktionen — ID-Generierung, Pfad-Konstruktion
apps/boilerplate/src/lib/storage/types.tsTypeScript-Typen für Upload-Ergebnisse, Validierung
apps/boilerplate/src/app/api/upload/route.tsPOST-Upload-Endpunkt mit Zod-Validierung
apps/boilerplate/src/app/api/files/[id]/route.tsGET- und DELETE-Endpunkte für die Dateiverwaltung
apps/boilerplate/src/components/upload/5 UI-Komponenten (DropZone, StandardInput, Preview, TierAware, Settings)
apps/boilerplate/src/lib/validations/api-schemas.tsZod-Schema für Upload-Parameter-Validierung
apps/boilerplate/src/lib/security/sanitization.tsDateiname-Bereinigungsfunktion