137 lines
6.8 KiB
TypeScript
137 lines
6.8 KiB
TypeScript
/**
|
|
* User Input Validation and Formatting Library
|
|
*
|
|
* Implements the rules defined in docs/input_validation_plan.md.
|
|
* Provides formatting functions and a Zod-integrated pipeline that:
|
|
* 1. Removes disallowed characters
|
|
* 2. Trims leading/trailing whitespace
|
|
* 3. Validates format (email)
|
|
* 4. Applies case transformation
|
|
* 5. Truncates to max length
|
|
*/
|
|
|
|
// ─── Character class definitions ───────────────────────────────
|
|
|
|
const LETTERS_NUMBERS = 'a-zA-Z0-9'
|
|
const LETTERS_NUMBERS_HYPHEN = `${LETTERS_NUMBERS}\\-`
|
|
const LETTERS_SPACES_HYPHEN = 'a-zA-Z\\s\\-'
|
|
const BASE_TEXT = `${LETTERS_NUMBERS}\\s\\-\\/`
|
|
const EXTENDED_TEXT = `${BASE_TEXT},`
|
|
|
|
// ─── Field type definitions ────────────────────────────────────
|
|
|
|
export type FieldType =
|
|
| 'name' | 'nationality' | 'streetAddress' | 'city'
|
|
| 'pickupLocation' | 'returnLocation' | 'country'
|
|
| 'fullAddress' | 'commercialName' | 'legalCompanyName'
|
|
| 'email'
|
|
| 'licensePlate' | 'vin' | 'cin' | 'passportNumber'
|
|
| 'internationalPermitNumber' | 'driverLicenseNumber'
|
|
| 'licenseCategory' | 'iceNumber' | 'commercialRegistry'
|
|
| 'carMark' | 'carModel' | 'carColor'
|
|
| 'zipCode'
|
|
|
|
interface FieldConfig {
|
|
maxLength: number
|
|
allowedPattern: RegExp
|
|
transform: (value: string) => string
|
|
}
|
|
|
|
// ─── Formatting functions ──────────────────────────────────────
|
|
|
|
/** Uppercase first letter of each word, lowercase the rest */
|
|
export function toTitleCase(str: string): string {
|
|
return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
}
|
|
|
|
/** Uppercase first letter of every word */
|
|
export function toTitleCaseAll(str: string): string {
|
|
return str.replace(/\b\w+/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
}
|
|
|
|
export function toLowerCase(str: string): string {
|
|
return str.toLowerCase()
|
|
}
|
|
|
|
/** Uppercase letters only, leave numbers unchanged */
|
|
export function toUpperCase(str: string): string {
|
|
return str.replace(/[a-zA-Z]/g, (char) => char.toUpperCase())
|
|
}
|
|
|
|
export function preserveOriginal(str: string): string {
|
|
return str
|
|
}
|
|
|
|
// ─── Field configuration ───────────────────────────────────────
|
|
|
|
const FIELD_CONFIGS: Record<FieldType, FieldConfig> = {
|
|
// Group 1: Title Case
|
|
name: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
|
nationality: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
|
streetAddress: { maxLength: 100, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
|
|
city: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
|
pickupLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
|
|
returnLocation: { maxLength: 50, allowedPattern: new RegExp('^[' + BASE_TEXT + ']*$'), transform: toTitleCase },
|
|
country: { maxLength: 50, allowedPattern: new RegExp('^[' + LETTERS_SPACES_HYPHEN + ']*$'), transform: toTitleCase },
|
|
|
|
// Group 2: Title Case All
|
|
fullAddress: { maxLength: 200, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
|
|
commercialName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
|
|
legalCompanyName: { maxLength: 100, allowedPattern: new RegExp('^[' + EXTENDED_TEXT + ']*$'), transform: toTitleCaseAll },
|
|
|
|
// Group 3: Lowercase — email
|
|
email: { maxLength: 100, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '@._%\\+\\-]*$'), transform: toLowerCase },
|
|
|
|
// Group 4: Uppercase (hyphens allowed per plan examples: "abc-123" → "ABC-123")
|
|
licensePlate: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
|
vin: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
|
cin: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
|
passportNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
|
internationalPermitNumber:{ maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
|
driverLicenseNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
|
licenseCategory: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
|
iceNumber: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
|
commercialRegistry: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS_HYPHEN + ']*$'), transform: toUpperCase },
|
|
|
|
// Group 5: Car fields
|
|
carMark: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase },
|
|
carModel: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase },
|
|
carColor: { maxLength: 30, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: toTitleCase },
|
|
|
|
// Group 6: Preserved (spaces for UK postcodes like "SW1A 1AA")
|
|
zipCode: { maxLength: 10, allowedPattern: new RegExp('^[' + LETTERS_NUMBERS + '\\s\\-]*$'), transform: preserveOriginal },
|
|
}
|
|
|
|
// ─── Public API ────────────────────────────────────────────────
|
|
|
|
/** Full processing pipeline: sanitize → trim → format → truncate */
|
|
export function sanitizeAndFormat(value: string, fieldType: FieldType): string {
|
|
const config = FIELD_CONFIGS[fieldType]
|
|
|
|
// Step 1: Remove disallowed characters
|
|
let sanitized = ''
|
|
for (const char of value) {
|
|
if (config.allowedPattern.test(char)) {
|
|
sanitized += char
|
|
}
|
|
}
|
|
|
|
// Step 2: Trim
|
|
sanitized = sanitized.trim()
|
|
|
|
// Step 3: Apply case transformation
|
|
const formatted = config.transform(sanitized)
|
|
|
|
// Step 4: Truncate
|
|
if (formatted.length > config.maxLength) {
|
|
return formatted.slice(0, config.maxLength)
|
|
}
|
|
|
|
return formatted
|
|
}
|
|
|
|
/** Get the max length for a given field type */
|
|
export function getMaxLength(fieldType: FieldType): number {
|
|
return FIELD_CONFIGS[fieldType].maxLength
|
|
}
|