first commit
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
Reference in New Issue
Block a user