CentercodePorygon
DittoPorygonAPI

File Storage

Secure file uploads with Vercel Blob

SafeFast

Overview

Use Vercel Blob for file storage with preset-based validation and scope-based access control. Files are stored with program/project isolation.

What it is

Vercel Blob storage with abstracted provider pattern, file presets, and permission checking utilities.

Why we use it

Built-in CDN, preset validation (size/type limits), scope-based access control, and soft delete support.

When to use

User file uploads, document storage, image assets, or any binary data that needs to be persisted and accessed.

Key Features

  • File presets with size and type validation
  • Scope-based access (PUBLIC/PROGRAM/PROJECT)
  • Automatic path prefixing by scope
  • Soft delete with restore capability

Quick Start

Uploading Files

Basic file upload with preset validation.

// Upload a file to Vercel Blob
import { uploadFile } from '@/features/files';

const result = await uploadFile({
  file: formData.get('file') as File,
  preset: 'image',           // image | document | video | audio
  purpose: 'DESIGN',         // FilePurpose enum (DESIGN, MEDIA, etc.)
  accessLevel: 'PROGRAM',    // PUBLIC | PROGRAM | PROJECT
  userId: currentUserId,     // Uploader's user ID
  scopeType: 'PROGRAM',      // PROGRAM | PROJECT (required with scopeId)
  scopeId: programId,
});

// result = { id, url, fileName, fileType, fileSizeBytes, category, ... }

Patterns

File Presets

Size limits and allowed types by preset.

// File presets with size limits and allowed types
import { FILE_PRESETS } from '@/lib/storage';

FILE_PRESETS.image = {
  accept: {
    'image/png': ['.png'],
    'image/jpeg': ['.jpg', '.jpeg'],
    'image/gif': ['.gif'],
    'image/webp': ['.webp'],
    'image/svg+xml': ['.svg'],
  },
  maxSizeBytes: 10 * 1024 * 1024,  // 10MB
  category: 'IMAGE',
};

FILE_PRESETS.document = {
  accept: {
    'application/pdf': ['.pdf'],
    'application/vnd.openxmlformats-officedocument...': ['.docx', '.xlsx'],
    'text/markdown': ['.md'],
    'text/csv': ['.csv'],
    // ... and more
  },
  maxSizeBytes: 25 * 1024 * 1024,  // 25MB
  category: 'DOCUMENT',
};

FILE_PRESETS.video = {
  accept: { 'video/mp4': ['.mp4'], 'video/webm': ['.webm'], 'video/quicktime': ['.mov'] },
  maxSizeBytes: 500 * 1024 * 1024, // 500MB
  category: 'MEDIA',
};

FILE_PRESETS.audio = {
  accept: { 'audio/mpeg': ['.mp3'], 'audio/wav': ['.wav'], 'audio/ogg': ['.ogg'], 'audio/webm': ['.webm'] },
  maxSizeBytes: 100 * 1024 * 1024, // 100MB
  category: 'MEDIA',
};

Access Control

Permission checks for upload and access.

// Access control levels
import { checkUploadPermission, checkFileAccess, checkDeletePermission } from '@/lib/storage';

// Before upload - check permission (scopeType, scopeId, userId)
const canUpload = await checkUploadPermission('PROGRAM', programId, userId);
if (!canUpload) throw new AuthorizationError('Cannot upload to this program');

// Before download - check access (file object, user object)
const canAccess = await checkFileAccess(
  { id: file.id, accessLevel: file.accessLevel, scopeType: file.scopeType, scopeId: file.scopeId, uploadedById: file.uploadedById },
  { id: userId }
);
if (!canAccess) throw new AuthorizationError('Cannot access this file');

// Before delete - check delete permission
const canDelete = await checkDeletePermission(file, userId);
if (!canDelete) throw new AuthorizationError('Cannot delete this file');

// Access rules:
// - PUBLIC: Anyone can access
// - PROGRAM: Must be program member
// - PROJECT: Must be project participant
// - Uploader always has access to their own files
// - Delete: Uploader OR scope admin (OWNER/BUILDER role)

Critical: Always check permissions before upload and access. The uploader always has access to their own files.

Upload API Route

Complete file upload endpoint pattern.

// API route for file upload
import { validateRequest } from '@/lib/validation';
import { handleError } from '@/lib/errors';
import { uploadFile, uploadFileSchema } from '@/features/files';
import { withAuth } from '@/lib/middleware';

export const POST = withAuth(async (request, { auth }) => {
  try {
    const formData = await request.formData();
    const file = formData.get('file') as File;
    const metadata = JSON.parse(formData.get('metadata') as string);

    const validation = validateRequest(metadata, uploadFileSchema);
    if (!validation.success) return validation.error;

    const result = await uploadFile({
      file,
      preset: validation.data.preset,
      purpose: validation.data.purpose,
      accessLevel: validation.data.accessLevel,
      userId: auth.user.id,
      scopeType: validation.data.scopeType,
      scopeId: validation.data.scopeId,
    });

    return NextResponse.json({ ok: true, data: result });
  } catch (error) {
    return handleError(error);
  }
});

Watch Out

Accepting files without preset validation

Don't

// Accepting any file type
const file = formData.get('file');
await uploadToBlob(file); // No validation!

Do

// Validate against preset
import { validateFile } from '@/lib/storage';

const file = formData.get('file') as File;
const validation = validateFile(file, 'image');
if (!validation.valid) {
  // validation.errorKey and validation.errorParams available for i18n
  throw new ValidationError(validation.errorKey ?? 'files.errors.invalidFile');
}
await uploadFile({ file, preset: 'image', purpose: 'DESIGN', ... });
  • Missing permission checks on upload/download
  • Not enforcing file size limits
  • Making files public when they should be scoped

Related

AI Integration

Document parsing with AI

Background Jobs

File processing jobs

Validation

File validation

External Documentation

Vercel Blob Documentation