/** * File upload helpers. Bun parses `multipart/form-data` natively via * `Request.formData()`, yielding web `File` objects; these helpers validate and * persist them safely (size/type limits, filename sanitisation to prevent path * traversal). */ export class UploadError extends Error { constructor(message: string) { super(message); this.name = "UploadError"; } } export interface SaveUploadOptions { /** Destination directory. */ dir: string; /** Reject files larger than this many bytes. */ maxBytes?: number; /** Allowed MIME types (e.g. "image/png") and/or extensions (e.g. ".png"). */ allowedTypes?: string[]; /** Choose the stored filename. Default: the sanitised original name. */ filename?: (file: File) => string; } export interface SavedUpload { path: string; filename: string; size: number; type: string; } /** All `File` values in a parsed form, with their field names. */ export function collectUploads(form: FormData): { field: string; file: File }[] { const out: { field: string; file: File }[] = []; for (const [field, value] of form) { if (value instanceof File && value.size > 0) out.push({ field, file: value }); } return out; } /** Validate and write one uploaded file to disk. Throws `UploadError` on reject. */ export async function saveUpload(file: File, options: SaveUploadOptions): Promise { if (options.maxBytes !== undefined && file.size > options.maxBytes) { throw new UploadError(`File "${file.name}" exceeds the ${options.maxBytes}-byte limit`); } if (options.allowedTypes && !isAllowed(file, options.allowedTypes)) { throw new UploadError(`File type not allowed: ${file.type || file.name || "unknown"}`); } const filename = sanitizeFilename( options.filename ? options.filename(file) : file.name || "upload", ); const path = `${options.dir.replace(/[/\\]+$/, "")}/${filename}`; await Bun.write(path, file); return { path, filename, size: file.size, type: file.type }; } function isAllowed(file: File, allowed: string[]): boolean { const type = (file.type || "").toLowerCase(); const name = (file.name || "").toLowerCase(); return allowed.some((entry) => { const e = entry.toLowerCase(); return e.startsWith(".") ? name.endsWith(e) : type === e; }); } /** Strip directory separators, traversal, and control chars from a filename. */ export function sanitizeFilename(name: string): string { const base = name .replace(/[/\\]+/g, "_") // path separators .replace(/\.\.+/g, ".") // collapse traversal dots // eslint-disable-next-line no-control-regex -- intentionally stripping control chars .replace(/[\x00-\x1f<>:"|?*]/g, "") // control + illegal chars .replace(/^\.+/, "") // no leading dots .trim(); return base.length > 0 ? base.slice(0, 255) : "upload"; }