release: WRNexusJS 0.7.0
This commit is contained in:
+160
-22
@@ -1,19 +1,41 @@
|
||||
/**
|
||||
* 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).
|
||||
* 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 {
|
||||
constructor(message: string) {
|
||||
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<UploadInspectionResult>;
|
||||
|
||||
export type UploadScanner = (input: {
|
||||
file: File;
|
||||
bytes: Uint8Array;
|
||||
filename: string;
|
||||
}) =>
|
||||
| boolean
|
||||
| { clean: boolean; reason?: string }
|
||||
| Promise<boolean | { clean: boolean; reason?: string }>;
|
||||
|
||||
export interface SaveUploadOptions {
|
||||
/** Destination directory. */
|
||||
/** Destination directory. Keep this outside the public web root. */
|
||||
dir: string;
|
||||
/** Reject files larger than this many bytes. */
|
||||
maxBytes?: number;
|
||||
@@ -21,6 +43,21 @@ export interface SaveUploadOptions {
|
||||
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<void>;
|
||||
}
|
||||
|
||||
export interface SecureUploadOptions extends Omit<SaveUploadOptions, "filename"> {
|
||||
/** 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 {
|
||||
@@ -28,51 +65,152 @@ export interface SavedUpload {
|
||||
filename: string;
|
||||
size: number;
|
||||
type: string;
|
||||
detectedType?: string;
|
||||
}
|
||||
|
||||
/** All `File` values in a parsed form, with their field names. */
|
||||
export function collectUploads(form: FormData): { field: string; file: File }[] {
|
||||
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) out.push({ field, file: value });
|
||||
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 to disk. Throws `UploadError` on reject. */
|
||||
/** Validate and write one uploaded file using a compatibility filename policy. */
|
||||
export async function saveUpload(file: File, options: SaveUploadOptions): Promise<SavedUpload> {
|
||||
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<SavedUpload> {
|
||||
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<SavedUpload> {
|
||||
if (options.maxBytes !== undefined && file.size > options.maxBytes) {
|
||||
throw new UploadError(`File "${file.name}" exceeds the ${options.maxBytes}-byte limit`);
|
||||
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"}`);
|
||||
throw new UploadError(
|
||||
`File type not allowed: ${file.type || file.name || "unknown"}`,
|
||||
"WRN-UPLOAD-TYPE",
|
||||
);
|
||||
}
|
||||
|
||||
const filename = sanitizeFilename(
|
||||
options.filename ? options.filename(file) : file.name || "upload",
|
||||
);
|
||||
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, file);
|
||||
return { path, filename, size: file.size, type: file.type };
|
||||
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 e = entry.toLowerCase();
|
||||
return e.startsWith(".") ? name.endsWith(e) : type === e;
|
||||
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, "_") // path separators
|
||||
.replace(/\.\.+/g, ".") // collapse traversal dots
|
||||
.replace(/[/\\]+/g, "_")
|
||||
.replace(/\.\.+/g, ".")
|
||||
// eslint-disable-next-line no-control-regex -- intentionally stripping control chars
|
||||
.replace(/[\x00-\x1f<>:"|?*]/g, "") // control + illegal chars
|
||||
.replace(/^\.+/, "") // no leading dots
|
||||
.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",
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user