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).
Das Kit konfiguriert Vercel Blob mit der EU-Region Frankfurt für die Datenspeicherung. Alle hochgeladenen Dateien werden innerhalb der EU gespeichert, was für die DSGVO-Konformität beim Umgang mit europäischen Benutzerdaten erforderlich ist. Die Region wird in der Storage-Service-Konfiguration festgelegt — keine zusätzliche Einrichtung erforderlich.
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
Wenn du einen Blob-Store über das Vercel-Dashboard erstellst und ihn mit deinem Projekt verknüpfst, wird
BLOB_READ_WRITE_TOKEN automatisch in deinen Vercel-Umgebungsvariablen gesetzt. Du musst ihn nur für die lokale Entwicklung zu apps/boilerplate/.env.local hinzufügen.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üfung | Regel | Fehlermeldung |
|---|---|---|
| Dateigröße | Max. 4,5 MB (Vercel Blob Limit) | "File size exceeds maximum of 4.5MB" |
| Dateityp | Nur JPEG, PNG, GIF, WebP, PDF | "File type {type} is not allowed" |
| Erweiterung | Blockiert .exe, .bat, .cmd, .sh, .ps1, .dll | "This file type is not allowed for security reasons" |
| Dateiname | Server-seitige Bereinigung über sanitizeFilename() | Nicht zutreffend (wird still bereinigt) |
Um zusätzliche Dateitypen zu erlauben, aktualisiere das
DEFAULT_ALLOWED_TYPES-Array in apps/boilerplate/src/lib/storage/validation.ts und die entsprechende MIME-Typ-Zuordnung in apps/boilerplate/src/lib/storage/vercel-blob.ts. Beide müssen übereinstimmen.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:
| Funktion | Free | Basic | Pro | Enterprise |
|---|---|---|---|---|
| Upload-Zugriff | Nein | Ja | Ja | Ja |
| Max. Dateigröße | — | 2 MB | 4,5 MB | 4,5 MB |
| Upload-UI | — | Standard-Datei-Picker | Drag & Drop | Drag & Drop |
| Dateitypen | — | Bilder, PDF | Bilder, PDF | Bilder, 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).Die Stufenerkennung liest das aktive Abonnement des Benutzers aus der Datenbank und ordnet die Lemon-Squeezy-Varianten-ID einem Stufennamen zu (
free, basic, pro, enterprise). Dieselbe Stufenlogik wird für KI-Rate-Limiting und Datei-Upload-Zugriff verwendet.UI-Komponenten
Das Kit enthält 5 Upload-bezogene Komponenten in
apps/boilerplate/src/components/upload/:| Komponente | Beschreibung |
|---|---|
DropZoneUpload | Drag-&-Drop-Zone mit visuellem Feedback, Dateityp-Icons und Upload-Fortschrittsbalken. Für Pro- und Enterprise-Stufen verfügbar. |
StandardFileInput | Herkömmliches Datei-Input mit einem gestalteten Button. Wird für die Basic-Stufe verwendet. |
FilePreview | Zeigt hochgeladene Datei mit Vorschaubild (Bilder) oder Icon (PDFs), Dateiname, Größe und einem Löschen-Button. |
TierAwareUpload | Wrapper, der DropZoneUpload oder StandardFileInput basierend auf der Abonnement-Stufe des Benutzers bedingt rendert. Zeigt kostenlose Benutzern eine Upgrade-Aufforderung. |
SettingsUploadSection | Vollstä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:
| Ebene | Schutz |
|---|---|
| Dateiname-Bereinigung | sanitizeFilename() entfernt Path-Traversal, Sonderzeichen und Null-Bytes |
| Erweiterungssperrung | Weist ausführbare Erweiterungen zurück (.exe, .bat, .cmd, .sh, .ps1, .dll) |
| MIME-Typ-Validierung | Erlaubt nur konfigurierte Content-Types (Bilder, PDF) |
| Größenlimits | Hartes 4,5-MB-Limit (Vercel Blob Maximum) |
| Benutzer-Eigentümerschaft | Alle Dateioperationen verifizieren userId — Benutzer können nur auf ihre eigenen Dateien zugreifen |
| Rate Limiting | Upload-Endpunkt durch withRateLimit('upload') geschützt — 10 Req/Stunde pro Benutzer, 20/Stunde pro IP |
| Zod-Validierung | Server-seitige Parametervalidierung mit zentralisierten Schemas |
| Orphan-Cleanup | Wenn das Datenbank-Speichern nach dem Upload fehlschlägt, wird der Blob automatisch gelöscht |
Zu Vercel Blob hochgeladene Dateien haben standardmäßig öffentliche URLs (
access: 'public'). Jeder mit der URL kann auf die Datei zugreifen. Wenn du private Dateien benötigst, ändere den Zugriffsmodus auf 'private' und verwende signierte URLs — siehe die Vercel Blob Dokumentation.Umgebungsvariablen
| Variable | Erforderlich | Zweck |
|---|---|---|
BLOB_READ_WRITE_TOKEN | Ja | Vercel Blob Storage Token für Uploads und Löschungen |
Wichtige Dateien
| Datei | Zweck |
|---|---|
apps/boilerplate/src/lib/storage/vercel-blob.ts | Blob-SDK-Wrapper — Upload, Löschen, Auflisten, Speicherstatistiken |
apps/boilerplate/src/lib/storage/validation.ts | Dateivalidierung — Größenlimits, erlaubte Typen, Sicherheitsüberprüfungen |
apps/boilerplate/src/lib/storage/file-utils.ts | Hilfsfunktionen — ID-Generierung, Pfad-Konstruktion |
apps/boilerplate/src/lib/storage/types.ts | TypeScript-Typen für Upload-Ergebnisse, Validierung |
apps/boilerplate/src/app/api/upload/route.ts | POST-Upload-Endpunkt mit Zod-Validierung |
apps/boilerplate/src/app/api/files/[id]/route.ts | GET- 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.ts | Zod-Schema für Upload-Parameter-Validierung |
apps/boilerplate/src/lib/security/sanitization.ts | Dateiname-Bereinigungsfunktion |