release: WRNexusJS 0.7.0

This commit is contained in:
2026-08-01 10:04:42 +05:30
parent c54144f2e4
commit 87507edf59
207 changed files with 12607 additions and 679 deletions
+160 -22
View File
@@ -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",
});
}