79 lines
2.8 KiB
TypeScript
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";
|
|
}
|