217 lines
7.0 KiB
TypeScript
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",
|
|
});
|
|
}
|