Files
WRNexusJS/packages/core/src/uploads.ts
T
2026-08-01 10:04:42 +05:30

217 lines
7.0 KiB
TypeScript

/**
* 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 {
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. Keep this outside the public web root. */
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;
/** 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 {
path: string;
filename: string;
size: number;
type: string;
detectedType?: string;
}
/** All `File` values in a parsed form, with their field names. */
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) 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 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`,
"WRN-UPLOAD-SIZE",
);
}
if (options.allowedTypes && !isAllowed(file, options.allowedTypes)) {
throw new UploadError(
`File type not allowed: ${file.type || file.name || "unknown"}`,
"WRN-UPLOAD-TYPE",
);
}
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, 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 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, "_")
.replace(/\.\.+/g, ".")
// eslint-disable-next-line no-control-regex -- intentionally stripping control chars
.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",
});
}