import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsNotEmpty, IsString, Matches, Validate, ValidatorConstraint, } from 'class-validator'; const EMAIL_PART_COUNT = 2; const FIRST_CHARACTER_INDEX = 0; const MAX_EMAIL_LENGTH = 254; const ASCII_SPACE = 32; const ASCII_DELETE = 127; // bcrypt reads at most 72 bytes; printable ASCII keeps characters == bytes. const MIN_PASSWORD_LENGTH = 8; const MAX_PASSWORD_BYTES = 72; const PASSWORD_PATTERN = `^[\\x20-\\x7E]{${MIN_PASSWORD_LENGTH},${MAX_PASSWORD_BYTES}}$`; @ValidatorConstraint({ name: 'email' }) class EmailConstraint { validate(value: unknown): boolean { return ( typeof value === 'string' && value.length <= MAX_EMAIL_LENGTH && value.split('@').length === EMAIL_PART_COUNT && !value .split('') .some( (character) => /\s/.test(character) || character.charCodeAt(FIRST_CHARACTER_INDEX) < ASCII_SPACE || character.charCodeAt(FIRST_CHARACTER_INDEX) === ASCII_DELETE, ) && value.split('@').every(Boolean) ); } defaultMessage(): string { return 'must be a valid email address'; } } // Login stays lenient: accounts created under an older policy must still sign in. @ValidatorConstraint({ name: 'passwordMaxBytes' }) class PasswordMaxBytesConstraint { validate(value: unknown): boolean { return ( typeof value === 'string' && Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES ); } defaultMessage(): string { return `must not exceed ${MAX_PASSWORD_BYTES} UTF-8 bytes`; } } class EmailDto { @ApiProperty({ example: 'user@example.com' }) @IsString() @Transform(({ value }: { value: unknown }) => typeof value === 'string' ? value.trim().toLowerCase() : value, ) @Validate(EmailConstraint) declare email: string; } export class RegisterDto extends EmailDto { @ApiProperty({ description: `Raw password: ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.`, pattern: PASSWORD_PATTERN, example: 'a secure password', }) @IsString() @Matches(new RegExp(PASSWORD_PATTERN), { message: `must be ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters`, }) declare password: string; } export class LoginDto extends EmailDto { @ApiProperty({ description: `Raw password, at most ${MAX_PASSWORD_BYTES} UTF-8 bytes. Not trimmed or normalized.`, example: 'a secure password', }) @IsString() @IsNotEmpty() @Validate(PasswordMaxBytesConstraint) declare password: string; }