graphql-upload-ts
Version:
TypeScript-first middleware and Upload scalar for GraphQL multipart requests (file uploads) with support for Apollo Server, Express, Koa, and more.
136 lines (111 loc) • 4.17 kB
text/typescript
import type { UploadOptions } from './process-request';
export interface ValidationResult {
isValid: boolean;
error?: string;
}
/**
* Maximum size for non-file form fields (in bytes).
* This applies to text fields like 'operations' and 'map' in GraphQL multipart requests.
* These fields contain JSON data (query, variables, and file mappings).
* Default: 1MB - sufficient for large GraphQL queries and variable sets.
*/
export const DEFAULT_MAX_FIELD_SIZE = 1_000_000; // 1MB
/**
* Maximum size for uploaded files (in bytes).
* This applies to the actual binary file content being uploaded.
* Default: 5MB - a reasonable limit for most web applications.
* Can be overridden per request for larger files (videos, high-res images, etc.).
*/
export const DEFAULT_MAX_FILE_SIZE = 5_000_000; // 5MB
/**
* Maximum number of files that can be uploaded in a single request.
* Default: Infinity - no limit on the number of files.
* Should be set to a reasonable number in production to prevent abuse.
*/
export const DEFAULT_MAX_FILES = Number.POSITIVE_INFINITY;
export function validateOptions(options?: UploadOptions): UploadOptions {
const validatedOptions: Required<UploadOptions> = {
maxFieldSize: options?.maxFieldSize ?? DEFAULT_MAX_FIELD_SIZE,
maxFileSize: options?.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
maxFiles: options?.maxFiles ?? DEFAULT_MAX_FILES,
};
if (validatedOptions.maxFieldSize <= 0) {
throw new Error('maxFieldSize must be a positive number');
}
if (validatedOptions.maxFileSize <= 0) {
throw new Error('maxFileSize must be a positive number');
}
if (validatedOptions.maxFiles <= 0) {
throw new Error('maxFiles must be a positive number');
}
if (
!Number.isInteger(validatedOptions.maxFiles) &&
validatedOptions.maxFiles !== Number.POSITIVE_INFINITY
) {
throw new Error('maxFiles must be an integer');
}
return validatedOptions;
}
export function validateMimeType(mimetype: string, allowedTypes?: string[]): ValidationResult {
if (!allowedTypes || allowedTypes.length === 0) {
return { isValid: true };
}
const isValid = allowedTypes.some((type) => {
if (type.endsWith('/*')) {
const prefix = type.slice(0, -2);
return mimetype.startsWith(`${prefix}/`);
}
return mimetype === type;
});
return {
isValid,
error: isValid ? undefined : `File type '${mimetype}' is not allowed`,
};
}
export function validateFileExtension(
filename: string,
allowedExtensions?: string[]
): ValidationResult {
if (!allowedExtensions || allowedExtensions.length === 0) {
return { isValid: true };
}
const extension = filename.split('.').pop()?.toLowerCase();
if (!extension) {
return {
isValid: false,
error: 'File must have an extension',
};
}
const isValid = allowedExtensions.some((ext) => ext.toLowerCase() === extension);
return {
isValid,
error: isValid ? undefined : `File extension '.${extension}' is not allowed`,
};
}
export function sanitizeFilename(filename: string): string {
// Remove path traversal attempts
let sanitized = filename.replace(/\.\./g, '');
// Remove directory separators
sanitized = sanitized.replace(/[/\\]/g, '');
// Remove control characters and non-printable characters
// biome-ignore lint/suspicious/noControlCharactersInRegex: Intentional to remove control characters
sanitized = sanitized.replace(/[\x00-\x1f\x7f-\x9f]/g, '');
// Remove leading/trailing dots and spaces
sanitized = sanitized.replace(/^[\s.]+|[\s.]+$/g, '');
// If filename is empty after sanitization, generate a default name
if (!sanitized) {
sanitized = `upload_${Date.now()}`;
}
// Limit filename length
const maxLength = 255;
if (sanitized.length > maxLength) {
const extension = sanitized.split('.').pop();
const nameWithoutExt = sanitized.substring(0, sanitized.lastIndexOf('.'));
if (extension && extension.length < 20) {
sanitized = `${nameWithoutExt.substring(0, maxLength - extension.length - 1)}.${extension}`;
} else {
sanitized = sanitized.substring(0, maxLength);
}
}
return sanitized;
}