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#
| Driver | Description |
|---|---|
| LocalDiskStorage | Saves files to disk. Auto-creates directories. |
| MemoryStorage | Stores files in memory as ArrayBuffer. Good for processing. |
Custom Storage Driver#
Implement the StorageDriver interface to use S3, GCS, or any other storage:
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"),
});StorageDriver requires two methods: handleFile(file, name, ext) and optional deleteFile(path).Basic Usage#
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#
| Option | Type | Description |
|---|---|---|
| storage | StorageDriver | Required. LocalDiskStorage or MemoryStorage |
| maxFileSize | number | Global max file size in bytes |
| allowedMimeTypes | MimeType[] | Global allowed MIME types |
| filename | (originalName, file) => { name, ext } | Global filename generator |
| fields | Record<string, UploadFieldConfig> | Per-field config (required, size, types, filename) |
| verifyMagicBytes | boolean | Verify 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.
image/png but without the PNG signature (89 50 4E 47) will be rejected.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.
| Category | Formats |
|---|---|
| Images | JPEG, PNG, GIF, WebP, BMP, TIFF, ICO, AVIF, HEIC, HEIF |
| Videos | MP4, WebM, OGG, QuickTime, AVI, Matroska, MPEG |
| Audio | MP3, WAV, OGG, WebM, AAC, FLAC, M4A |
| Documents | PDF, RTF |
| Office | DOC, DOCX, XLS, XLSX, PPT, PPTX |
| OpenDocument | ODT, ODS, ODP |
| Archives | ZIP, 7Z, RAR, GZIP, TAR, BZIP2 |
| Fonts | WOFF, WOFF2, TTF, OTF |
UploadFieldConfig#
| Field | Type | Description |
|---|---|---|
| required | boolean | Reject if field is missing (default: false) |
| maxFileSize | number | Max size in bytes (overrides global) |
| allowedMimeTypes | MimeType[] | Allowed types (overrides global) |
| filename | (originalName, file) => { name, ext } | Filename generator (overrides global) |
| outputFormat | "webp" | "png" | "jpeg" | "avif" | Force image conversion on upload |
| multiple | boolean | Accept 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:
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#
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
},
},
});BadRequestError if validation fails (size, MIME type, magic bytes mismatch, or required fields missing).UploadedFile#
| Property | Type | Description |
|---|---|---|
| originalName | string | Original filename from user |
| name | string | Generated name (with UUID) |
| ext | string | File extension with dot (e.g. .png) |
| size | number | File size in bytes |
| type | string | MIME type |
| buffer | ArrayBuffer | File data (MemoryStorage only) |
| path | string | Absolute path on disk (LocalDiskStorage only) |
Result#
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:
| Configuration | Type |
|---|---|
| { required: true } | UploadedFile |
| { maxFileSize: ... } | UploadedFile | undefined |
| { allowedMimeTypes: [...] } | UploadedFile | undefined |
| { maxFileSize, allowedMimeTypes } | UploadedFile | undefined |
| {} (no config) | UploadedFile | UploadedFile[] | undefined |
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:
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#
// 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#
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:
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#
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);