Files
WRNexusJS/packages/core/src/uploads.ts
T
2026-07-12 15:55:18 +05:30

79 lines
2.8 KiB
TypeScript

/**
* 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<SavedUpload> {
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";
}