File Upload#

Parse multipart/form-data requests for file uploads. Supports storage drivers, field validation, magic bytes verification, custom filenames, and middleware mode.

Storage Drivers#

DriverDescription
LocalDiskStorageSaves files to disk. Auto-creates directories.
MemoryStorageStores files in memory as ArrayBuffer. Good for processing.

Custom Storage Driver#

Implement the StorageDriver interface to use S3, GCS, or any other storage:

typescript
import type { StorageDriver, UploadedFile } from "@buntok/core/upload";

class S3Storage implements StorageDriver {
  constructor(private bucket: string, private region: string) {}

  async handleFile(file: File, name: string, ext: string): Promise<UploadedFile> {
    const key = `uploads/${name}${ext}`;
    await s3.putObject({
      Bucket: this.bucket,
      Key: key,
      Body: file,
      ContentType: file.type,
    });

    return {
      originalName: file.name,
      name,
      ext,
      size: file.size,
      type: file.type,
      path: key, // S3 key as "path"
    };
  }

  async deleteFile(key: string): Promise<boolean> {
    await s3.deleteObject({ Bucket: this.bucket, Key: key });
    return true;
  }
}

// Usage
const result = await handleUploads(ctx, {
  storage: new S3Storage("my-bucket", "us-east-1"),
});
Info
StorageDriver requires two methods: handleFile(file, name, ext) and optional deleteFile(path).

Basic Usage#

typescript
import { handleUploads, LocalDiskStorage } from "@buntok/core/upload";

app.post("/upload", async (ctx) => {
  const result = await handleUploads(ctx, {
    storage: new LocalDiskStorage("./uploads"),
  });

  return ctx.json({ files: result.files.map(f => f.name) });
});

Options#

OptionTypeDescription
storageStorageDriverRequired. LocalDiskStorage or MemoryStorage
maxFileSizenumberGlobal max file size in bytes
allowedMimeTypesMimeType[]Global allowed MIME types
filename(originalName, file) => { name, ext }Global filename generator
fieldsRecord<string, UploadFieldConfig>Per-field config (required, size, types, filename)
verifyMagicBytesbooleanVerify file magic bytes against MIME type (default: true)

Magic Bytes Verification#

By default, handleUploads verifies that the file's actual content matches the claimed MIME type by checking magic bytes (file signatures). This prevents users from uploading malicious files with spoofed MIME types.

Warning
Magic bytes verification is enabled by default. A file claiming to be image/png but without the PNG signature (89 50 4E 47) will be rejected.
typescript
import { handleUploads, LocalDiskStorage } from "@buntok/core/upload";

// Magic bytes verification ON (default)
const result = await handleUploads(ctx, {
  storage: new LocalDiskStorage("./uploads"),
  allowedMimeTypes: ["image/png", "image/jpeg"],
});

// Disable magic bytes verification (not recommended)
const result = await handleUploads(ctx, {
  storage: new LocalDiskStorage("./uploads"),
  verifyMagicBytes: false,
});

Supported Formats#

Magic bytes verification supports 45+ formats including images, videos, audio, documents, archives, and fonts. Text-based formats (JSON, HTML, CSS, etc.) skip verification as they have no reliable magic bytes.

CategoryFormats
ImagesJPEG, PNG, GIF, WebP, BMP, TIFF, ICO, AVIF, HEIC, HEIF
VideosMP4, WebM, OGG, QuickTime, AVI, Matroska, MPEG
AudioMP3, WAV, OGG, WebM, AAC, FLAC, M4A
DocumentsPDF, RTF
OfficeDOC, DOCX, XLS, XLSX, PPT, PPTX
OpenDocumentODT, ODS, ODP
ArchivesZIP, 7Z, RAR, GZIP, TAR, BZIP2
FontsWOFF, WOFF2, TTF, OTF

UploadFieldConfig#

FieldTypeDescription
requiredbooleanReject if field is missing (default: false)
maxFileSizenumberMax size in bytes (overrides global)
allowedMimeTypesMimeType[]Allowed types (overrides global)
filename(originalName, file) => { name, ext }Filename generator (overrides global)
outputFormat"webp" | "png" | "jpeg" | "avif"Force image conversion on upload
multiplebooleanAccept multiple files for this field (default: false)

Image Conversion#

Use outputFormat to automatically convert uploaded images. The result is an ImageUploadedFile with metadata like width, height, and format:

typescript
const result = await handleUploads(ctx, {
  storage: new LocalDiskStorage("./uploads"),
  fields: {
    avatar: {
      required: true,
      outputFormat: "webp", // Convert to WebP
    },
  },
});

// result.fields.avatar is ImageUploadedFile
const avatar = result.fields.avatar;
if (avatar.kind === "image") {
  console.log(avatar.width, avatar.height, avatar.format);
  // avatar.originalType / avatar.originalExt if format changed
}

Validation#

typescript
import { handleUploads, LocalDiskStorage } from "@buntok/core/upload";

const result = await handleUploads(ctx, {
  storage: new LocalDiskStorage("./uploads"),
  maxFileSize: 5 * 1024 * 1024, // 5MB global
  allowedMimeTypes: ["image/jpeg", "image/png", "image/webp"],
  fields: {
    avatar: {
      required: true,
      maxFileSize: 2 * 1024 * 1024, // 2MB (overrides global)
      allowedMimeTypes: ["image/jpeg", "image/png"],
    },
    document: {
      required: false,
      maxFileSize: 10 * 1024 * 1024, // 10MB
    },
  },
});
Warning
Throws BadRequestError if validation fails (size, MIME type, magic bytes mismatch, or required fields missing).

UploadedFile#

PropertyTypeDescription
originalNamestringOriginal filename from user
namestringGenerated name (with UUID)
extstringFile extension with dot (e.g. .png)
sizenumberFile size in bytes
typestringMIME type
bufferArrayBufferFile data (MemoryStorage only)
pathstringAbsolute path on disk (LocalDiskStorage only)

Result#

typescript
const result = await handleUploads(ctx, options);

result.fields.name       // string (text field)
result.fields.avatar     // UploadedFile (file field)
result.fields.gallery    // UploadedFile[] (multiple files with same key)
result.files             // UploadedFile[] (all files, flattened)

Type-Safe Field Inference#

The ParseUploadResult type automatically infers the correct type based on your field configuration:

ConfigurationType
{ required: true }UploadedFile
{ maxFileSize: ... }UploadedFile | undefined
{ allowedMimeTypes: [...] }UploadedFile | undefined
{ maxFileSize, allowedMimeTypes }UploadedFile | undefined
{} (no config)UploadedFile | UploadedFile[] | undefined
typescript
const result = await handleUploads(ctx, {
  storage: new LocalDiskStorage("./uploads"),
  fields: {
    avatar: {
      required: true,  // → UploadedFile (guaranteed)
      maxFileSize: 1024 * 1024 * 5,
    },
    thumbnail: {
      maxFileSize: 1024 * 1024,  // → UploadedFile | undefined
    },
    docs: {},  // → UploadedFile | UploadedFile[] | undefined
  },
});

// Autocomplete works correctly:
result.fields.avatar.name;      // ✅ OK (UploadedFile)
result.fields.thumbnail?.name;  // ✅ OK (UploadedFile | undefined)
// result.fields.docs.name;     // ❌ Error (need to narrow type first)

Uploader Middleware#

Use uploader() as middleware to auto-parse uploads and store results in ctx.store:

typescript
import { uploader, LocalDiskStorage } from "@buntok/core/upload";

const upload = uploader({
  storage: new LocalDiskStorage("./uploads"),
  maxFileSize: 5 * 1024 * 1024,
  fields: {
    avatar: { required: true },
  },
});

// As route middleware
app.post("/profile", upload, async (ctx) => {
  const files = ctx.store.files;  // UploadedFile[]
  const fields = ctx.store.fields;
  return ctx.json({ uploaded: files.length });
});

// As global middleware
app.use(uploader({
  storage: new LocalDiskStorage("./uploads"),
}));

// As group middleware
const api = app.group("/api");
api.use(uploader({ storage: new LocalDiskStorage("./uploads") }));

Custom Filename#

typescript
// Global custom filename
const result = await handleUploads(ctx, {
  storage: new LocalDiskStorage("./uploads"),
  filename: (originalName, file) => {
    const ext = originalName.split(".").pop();
    return { name: `upload-${Date.now()}`, ext: `.${ext}` };
  },
});

// Per-field custom filename (overrides global)
const result = await handleUploads(ctx, {
  storage: new LocalDiskStorage("./uploads"),
  fields: {
    avatar: {
      filename: (originalName, file) => {
        return { name: `avatar-${Date.now()}`, ext: ".jpg" };
      },
    },
  },
});

Delete Uploaded File#

typescript
import { deleteUploadedFile, LocalDiskStorage } from "@buntok/core/upload";

const storage = new LocalDiskStorage("./uploads");
const result = await handleUploads(ctx, { storage });

// Delete a specific file
const file = result.fields.avatar;
if (file) {
  await deleteUploadedFile(storage, file);
}

Rate Limiting#

Combine with the built-in rate limiter to prevent upload abuse:

typescript
import { uploader, LocalDiskStorage } from "@buntok/core/upload";
import { rateLimiter, slidingWindowRateLimiter } from "@buntok/core";

// Rate limit uploads to 10 per hour per user
app.post("/upload",
  rateLimiter({ windowMs: 60 * 60 * 1000, max: 10 }),
  uploader({ storage: new LocalDiskStorage("./uploads") }),
  async (ctx) => {
    return ctx.json({ uploaded: true });
  }
);

// Rate limit by IP with sliding window
app.post("/upload",
  slidingWindowRateLimiter({
    windowMs: 60 * 60 * 1000,
    max: 10,
    keyGenerator: (ctx) => ctx.request.ip,
  }),
  uploader({ storage: new LocalDiskStorage("./uploads") }),
  async (ctx) => {
    return ctx.json({ uploaded: true });
  }
);

Full Example#

typescript
import { Buntok } from "@buntok/core";
import { handleUploads, LocalDiskStorage } from "@buntok/core/upload";

const app = new Buntok();

const storage = new LocalDiskStorage("./uploads");

// Single file upload
app.post("/avatar", async (ctx) => {
  const result = await handleUploads(ctx, {
    storage,
    maxFileSize: 2 * 1024 * 1024, // 2MB
    fields: {
      avatar: {
        required: true,
        allowedMimeTypes: ["image/jpeg", "image/png"],
      },
    },
  });

  const avatar = result.fields.avatar;
  return ctx.json({ path: avatar.path, name: avatar.name });
});

// Multiple file upload
app.post("/gallery", async (ctx) => {
  const result = await handleUploads(ctx, {
    storage,
    maxFileSize: 10 * 1024 * 1024, // 10MB
    fields: {
      images: {
        required: true,
        allowedMimeTypes: ["image/jpeg", "image/png", "image/webp"],
      },
    },
  });

  const images = result.fields.images; // UploadedFile[]
  return ctx.json({ count: images.length });
});

// Text fields + file
app.post("/post", async (ctx) => {
  const result = await handleUploads(ctx, {
    storage,
    fields: {
      cover: { required: true },
    },
  });

  const title = result.fields.title;  // string (text field)
  const cover = result.fields.cover;  // UploadedFile (file field)

  const post = await db.post.create({
    data: { title, coverPath: cover.path },
  });

  return ctx.json({ id: post.id });
});

app.listen(1212);