/** * File upload helpers. The legacy `saveUpload` keeps the original sanitized * filename for compatibility. New applications should use `saveUploadSecure`, * which stores a random name and supports content inspection/scanning hooks. */ export class UploadError extends Error { readonly code: string; constructor(message: string, code = "WRN-UPLOAD-REJECTED") { super(message); this.name = "UploadError"; this.code = code; } } export interface UploadInspectionResult { allowed: boolean; detectedType?: string; reason?: string; } export type UploadInspector = (input: { file: File; bytes: Uint8Array; filename: string; }) => UploadInspectionResult | Promise; export type UploadScanner = (input: { file: File; bytes: Uint8Array; filename: string; }) => | boolean | { clean: boolean; reason?: string } | Promise; export interface SaveUploadOptions { /** Destination directory. Keep this outside the public web root. */ 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; /** Content/magic-byte inspection hook. */ inspect?: UploadInspector; /** Malware scanning hook. */ scan?: UploadScanner; /** Called after validation but before persistence. */ beforeSave?: (input: { file: File; bytes: Uint8Array; filename: string }) => void | Promise; } export interface SecureUploadOptions extends Omit { /** Preserve the original sanitized name instead of a random server name. */ preserveOriginalName?: boolean; /** Optional custom secure filename generator. */ filename?: (file: File) => string; /** Preserve a conservative extension on random filenames. Defaults to true. */ preserveExtension?: boolean; } export interface SavedUpload { path: string; filename: string; size: number; type: string; detectedType?: string; } /** All `File` values in a parsed form, with their field names. */ export function collectUploads( form: FormData, options: { maxFiles?: number; maxTotalBytes?: number } = {}, ): { field: string; file: File }[] { const out: { field: string; file: File }[] = []; let totalBytes = 0; for (const [field, value] of form) { if (!(value instanceof File) || value.size <= 0) continue; out.push({ field, file: value }); totalBytes += value.size; if (options.maxFiles !== undefined && out.length > options.maxFiles) { throw new UploadError( `Upload contains more than ${options.maxFiles} files.`, "WRN-UPLOAD-FILE-COUNT", ); } if (options.maxTotalBytes !== undefined && totalBytes > options.maxTotalBytes) { throw new UploadError( `Upload exceeds the ${options.maxTotalBytes}-byte aggregate limit.`, "WRN-UPLOAD-TOTAL-SIZE", ); } } return out; } /** Validate and write one uploaded file using a compatibility filename policy. */ export async function saveUpload(file: File, options: SaveUploadOptions): Promise { return persistUpload( file, options, sanitizeFilename(options.filename ? options.filename(file) : file.name || "upload"), ); } /** Store an upload under a random server-generated name by default. */ export async function saveUploadSecure( file: File, options: SecureUploadOptions, ): Promise { const requested = options.filename?.(file); const filename = requested ? sanitizeFilename(requested) : options.preserveOriginalName ? sanitizeFilename(file.name || "upload") : randomUploadFilename(file.name, options.preserveExtension !== false); return persistUpload(file, options, filename); } async function persistUpload( file: File, options: SaveUploadOptions, filename: string, ): Promise { if (options.maxBytes !== undefined && file.size > options.maxBytes) { throw new UploadError( `File "${file.name}" exceeds the ${options.maxBytes}-byte limit`, "WRN-UPLOAD-SIZE", ); } if (options.allowedTypes && !isAllowed(file, options.allowedTypes)) { throw new UploadError( `File type not allowed: ${file.type || file.name || "unknown"}`, "WRN-UPLOAD-TYPE", ); } const bytes = new Uint8Array(await file.arrayBuffer()); let detectedType: string | undefined; if (options.inspect) { const result = await options.inspect({ file, bytes, filename }); if (!result.allowed) { throw new UploadError(result.reason ?? "File content is not allowed.", "WRN-UPLOAD-CONTENT"); } detectedType = result.detectedType; } if (options.scan) { const result = await options.scan({ file, bytes, filename }); const clean = typeof result === "boolean" ? result : result.clean; if (!clean) { throw new UploadError( typeof result === "boolean" ? "File failed malware scanning." : (result.reason ?? "File failed malware scanning."), "WRN-UPLOAD-MALWARE", ); } } await options.beforeSave?.({ file, bytes, filename }); const path = `${options.dir.replace(/[/\\]+$/, "")}/${filename}`; await Bun.write(path, bytes); return { path, filename, size: file.size, type: file.type, ...(detectedType ? { detectedType } : {}), }; } function isAllowed(file: File, allowed: string[]): boolean { const type = (file.type || "").toLowerCase(); const name = (file.name || "").toLowerCase(); return allowed.some((entry) => { const candidate = entry.toLowerCase(); if (candidate.startsWith(".")) return name.endsWith(candidate); if (candidate.endsWith("/*")) return type.startsWith(candidate.slice(0, -1)); return type === candidate; }); } /** Strip directory separators, traversal, and control chars from a filename. */ export function sanitizeFilename(name: string): string { const base = name .replace(/[/\\]+/g, "_") .replace(/\.\.+/g, ".") // eslint-disable-next-line no-control-regex -- intentionally stripping control chars .replace(/[\x00-\x1f<>:"|?*]/g, "") .replace(/^\.+/, "") .trim(); return base.length > 0 ? base.slice(0, 255) : "upload"; } export function randomUploadFilename(originalName = "", preserveExtension = true): string { const bytes = new Uint8Array(16); crypto.getRandomValues(bytes); const id = [...bytes].map((value) => value.toString(16).padStart(2, "0")).join(""); if (!preserveExtension) return id; const match = /(?:^|\.)([A-Za-z0-9]{1,10})$/.exec(originalName); return match ? `${id}.${match[1]!.toLowerCase()}` : id; } export function secureDownloadHeaders( filename: string, type = "application/octet-stream", ): Headers { const safe = sanitizeFilename(filename).replace(/["\\]/g, "_"); return new Headers({ "content-type": type, "content-disposition": `attachment; filename="${safe}"`, "x-content-type-options": "nosniff", "cache-control": "private, no-store", }); }