diff --git a/ospabhost/frontend/src/pages/register.tsx b/ospabhost/frontend/src/pages/register.tsx index c8bfa5b..148acf5 100644 --- a/ospabhost/frontend/src/pages/register.tsx +++ b/ospabhost/frontend/src/pages/register.tsx @@ -9,13 +9,17 @@ import { useToast } from '../hooks/useToast'; import { useTranslation } from '../i18n'; import { useLocalePath } from '../middleware'; import { validateEmail } from '../utils/emailValidation'; +import { validateUsername } from '../utils/usernameBlacklist'; +import { quickEmailCheck, verifyEmailWithAPI } from '../utils/emailVerification'; const RegisterPage = () => { const { addToast } = useToast(); const [username, setUsername] = useState(''); + const [usernameError, setUsernameError] = useState(null); const [email, setEmail] = useState(''); const [emailError, setEmailError] = useState(null); const [emailSuggestion, setEmailSuggestion] = useState(null); + const [isVerifyingEmail, setIsVerifyingEmail] = useState(false); const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -29,17 +33,57 @@ const RegisterPage = () => { const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY; + // Username validation on blur + const handleUsernameBlur = useCallback(() => { + if (!username.trim()) { + setUsernameError(null); + return; + } + + const result = validateUsername(username, locale); + setUsernameError(result.isValid ? null : result.error ?? null); + }, [username, locale]); + // Email validation on blur - const handleEmailBlur = useCallback(() => { + const handleEmailBlur = useCallback(async () => { if (!email.trim()) { setEmailError(null); setEmailSuggestion(null); return; } + // Quick client-side check + const quickCheck = quickEmailCheck(email); + if (!quickCheck.valid) { + setEmailError(quickCheck.reason || (locale === 'en' ? 'Invalid email' : 'Недействительный email')); + setEmailSuggestion(null); + return; + } + const result = validateEmail(email, locale); setEmailError(result.isValid ? null : result.error ?? null); setEmailSuggestion(result.suggestion ?? null); + + // Optional: deep verification with API (only if basic checks pass) + if (result.isValid) { + setIsVerifyingEmail(true); + try { + const apiResult = await verifyEmailWithAPI(email, { checkMX: true }); + if (!apiResult.valid) { + setEmailError(apiResult.error || (locale === 'en' + ? 'This email address appears to be invalid' + : 'Этот email-адрес недействителен')); + } else if (apiResult.disposable) { + setEmailError(locale === 'en' + ? 'Temporary email addresses are not allowed' + : 'Временные email-адреса не допускаются'); + } + } catch (err) { + console.warn('Email verification failed:', err); + } finally { + setIsVerifyingEmail(false); + } + } }, [email, locale]); // Apply email suggestion @@ -75,6 +119,13 @@ const RegisterPage = () => { e.preventDefault(); setError(''); + // Validate username + const usernameValidation = validateUsername(username, locale); + if (!usernameValidation.isValid) { + setUsernameError(usernameValidation.error ?? t('auth.register.invalidEmail')); + return; + } + // Validate email before submit const emailValidation = validateEmail(email, locale); if (!emailValidation.isValid) { @@ -127,13 +178,28 @@ const RegisterPage = () => {

{t('auth.register.title')}

- setUsername(e.target.value)} - placeholder={t('auth.register.usernamePlaceholder')} - className="w-full px-5 py-3 mb-4 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-ospab-primary" - /> +
+ { + setUsername(e.target.value); + setUsernameError(null); + }} + onBlur={handleUsernameBlur} + placeholder={t('auth.register.usernamePlaceholder')} + className={`w-full px-5 py-3 border rounded-full focus:outline-none focus:ring-2 ${ + usernameError + ? 'border-red-500 focus:ring-red-500' + : 'border-gray-300 focus:ring-ospab-primary' + }`} + required + disabled={isLoading} + /> + {usernameError && ( +

{usernameError}

+ )} +
{ className={`w-full px-5 py-3 border rounded-full focus:outline-none focus:ring-2 ${ emailError ? 'border-red-500 focus:ring-red-500' + : isVerifyingEmail + ? 'border-yellow-400 focus:ring-yellow-400' : 'border-gray-300 focus:ring-ospab-primary' }`} + required + disabled={isLoading} /> + {isVerifyingEmail && ( +

+ {locale === 'en' ? 'Verifying email...' : 'Проверка email...'} +

+ )} {emailError && (

{emailError}

)} - {emailSuggestion && ( + {emailSuggestion && !emailError && ( {error && ( -

{error}

+
+

{error}

+
)} {/* Social networks */} @@ -211,38 +288,32 @@ const RegisterPage = () => {
-
+
diff --git a/ospabhost/frontend/src/utils/emailValidation.ts b/ospabhost/frontend/src/utils/emailValidation.ts index b123fde..785a79f 100644 --- a/ospabhost/frontend/src/utils/emailValidation.ts +++ b/ospabhost/frontend/src/utils/emailValidation.ts @@ -6,6 +6,19 @@ // Regex for basic email format validation const EMAIL_REGEX = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; +// Valid TLDs (top-level domains) +const VALID_TLDS = new Set([ + 'com', 'org', 'net', 'edu', 'gov', 'mil', 'int', + 'ru', 'рф', 'su', 'by', 'ua', 'kz', 'uz', 'kg', 'tj', 'am', 'az', 'ge', 'md', + 'uk', 'de', 'fr', 'it', 'es', 'pl', 'nl', 'be', 'ch', 'at', 'se', 'no', 'dk', 'fi', + 'cn', 'jp', 'kr', 'in', 'au', 'nz', 'ca', 'mx', 'br', 'ar', + 'io', 'co', 'me', 'tv', 'info', 'biz', 'name', 'pro', 'mobi', 'tel', 'asia', +]); + +// Minimum lengths for domain parts +const MIN_DOMAIN_LENGTH = 2; +const MIN_TLD_LENGTH = 2; + // Common disposable email domains (partial list) const DISPOSABLE_DOMAINS = new Set([ '10minutemail.com', @@ -59,6 +72,39 @@ export function validateEmailFormat(email: string): boolean { return EMAIL_REGEX.test(email.trim().toLowerCase()); } +/** + * Validate email domain (prevent a@a.a type emails) + */ +export function validateEmailDomain(email: string): { isValid: boolean; error?: string } { + const parts = email.split('@'); + if (parts.length !== 2) { + return { isValid: false, error: 'Invalid email format' }; + } + + const domain = parts[1].toLowerCase(); + const domainParts = domain.split('.'); + + // Must have at least 2 parts (domain.tld) + if (domainParts.length < 2) { + return { isValid: false, error: 'Invalid domain' }; + } + + // Check each part length + for (const part of domainParts) { + if (part.length < MIN_DOMAIN_LENGTH) { + return { isValid: false, error: 'Domain parts too short' }; + } + } + + // Check TLD + const tld = domainParts[domainParts.length - 1]; + if (tld.length < MIN_TLD_LENGTH || !VALID_TLDS.has(tld)) { + return { isValid: false, error: 'Invalid or unsupported domain' }; + } + + return { isValid: true }; +} + /** * Check if email domain is disposable */ @@ -106,6 +152,17 @@ export function validateEmail(email: string, locale: 'ru' | 'en' = 'ru'): EmailV }; } + // Check domain validity + const domainCheck = validateEmailDomain(trimmedEmail); + if (!domainCheck.isValid) { + return { + isValid: false, + error: locale === 'en' + ? 'Invalid email domain. Please use a valid email address.' + : 'Недействительный домен email. Используйте настоящий email.', + }; + } + // Check for disposable emails if (isDisposableEmail(trimmedEmail)) { return { diff --git a/ospabhost/frontend/src/utils/emailVerification.ts b/ospabhost/frontend/src/utils/emailVerification.ts new file mode 100644 index 0000000..60e46a9 --- /dev/null +++ b/ospabhost/frontend/src/utils/emailVerification.ts @@ -0,0 +1,174 @@ +/** + * External email verification API service + * Uses multiple free APIs for email validation + */ + +export interface EmailAPIResult { + valid: boolean; + disposable?: boolean; + freeProvider?: boolean; + error?: string; +} + +/** + * Verify email using Hunter.io email verifier API (free tier available) + * Alternative: emailvalidation.io, verify-email.org + * Currently unused but kept for future implementation + */ +export async function verifyWithHunter(email: string): Promise { + try { + // Using hunter.io free API endpoint + const response = await fetch( + `https://api.hunter.io/v2/email-verifier?email=${encodeURIComponent(email)}&api_key=demo`, + { + method: 'GET', + headers: { 'Accept': 'application/json' } + } + ); + + if (!response.ok) { + throw new Error('API request failed'); + } + + const data = await response.json(); + + if (data.data) { + return { + valid: data.data.status === 'valid' || data.data.result === 'deliverable', + disposable: data.data.disposable || false, + freeProvider: data.data.webmail || false, + }; + } + + return { valid: true }; // If API doesn't work, don't block + } catch (error) { + console.warn('Hunter API verification failed:', error); + return { valid: true }; // Fallback to allow registration + } +} + +/** + * Verify email using abstract email validation API + */ +async function verifyWithAbstract(email: string): Promise { + try { + const apiKey = import.meta.env.VITE_ABSTRACT_EMAIL_API_KEY; + if (!apiKey) { + return { valid: true }; // Skip if no API key + } + + const response = await fetch( + `https://emailvalidation.abstractapi.com/v1/?api_key=${apiKey}&email=${encodeURIComponent(email)}` + ); + + if (!response.ok) { + throw new Error('API request failed'); + } + + const data = await response.json(); + + return { + valid: data.deliverability !== 'UNDELIVERABLE' && data.is_valid_format?.value === true, + disposable: data.is_disposable_email?.value || false, + freeProvider: data.is_free_email?.value || false, + }; + } catch (error) { + console.warn('Abstract API verification failed:', error); + return { valid: true }; + } +} + +/** + * Simple DNS MX record check simulation + * In production, this should be done on the backend + */ +async function checkMXRecords(domain: string): Promise { + // This is a client-side limitation - MX record checks should be done on backend + // For now, we'll use a simple domain existence check via DNS-over-HTTPS + try { + const response = await fetch( + `https://dns.google/resolve?name=${encodeURIComponent(domain)}&type=MX`, + { method: 'GET' } + ); + + if (!response.ok) { + return true; // If check fails, don't block + } + + const data = await response.json(); + + // Check if MX records exist + return data.Answer && data.Answer.length > 0; + } catch (error) { + console.warn('MX record check failed:', error); + return true; // Fallback to allow + } +} + +/** + * Main email verification function with multiple methods + */ +export async function verifyEmailWithAPI( + email: string, + options: { useAPI?: boolean; checkMX?: boolean } = {} +): Promise { + const { useAPI = false, checkMX = true } = options; + + const domain = email.split('@')[1]; + if (!domain) { + return { valid: false, error: 'Invalid email format' }; + } + + // Check MX records first (lightweight) + if (checkMX) { + const hasMX = await checkMXRecords(domain); + if (!hasMX) { + return { + valid: false, + error: 'Email domain has no mail server', + }; + } + } + + // Use external API if enabled + if (useAPI) { + // Try Abstract API first (if key is available) + const abstractResult = await verifyWithAbstract(email); + if (!abstractResult.valid) { + return abstractResult; + } + + // Fallback to Hunter.io (free demo) + // const hunterResult = await verifyWithHunter(email); + // return hunterResult; + } + + return { valid: true }; +} + +/** + * Quick check if email might be valid (client-side only) + */ +export function quickEmailCheck(email: string): { valid: boolean; reason?: string } { + // Check for common invalid patterns + const invalidPatterns = [ + /^[a-z]@[a-z]\.[a-z]$/i, // a@a.a pattern + /^test@test\./i, // test@test.* + /^admin@admin\./i, // admin@admin.* + /^\d+@\d+\./, // numbers only + /@localhost$/i, // localhost domain + /@example\./i, // example domain + /@test\./i, // test domain + ]; + + for (const pattern of invalidPatterns) { + if (pattern.test(email)) { + return { + valid: false, + reason: 'This email address appears to be invalid or for testing purposes', + }; + } + } + + return { valid: true }; +} diff --git a/ospabhost/frontend/src/utils/usernameBlacklist.ts b/ospabhost/frontend/src/utils/usernameBlacklist.ts new file mode 100644 index 0000000..61c8024 --- /dev/null +++ b/ospabhost/frontend/src/utils/usernameBlacklist.ts @@ -0,0 +1,156 @@ +/** + * Username blacklist validation + * Prevents registration with profanity and reserved words + */ + +// Profanity and inappropriate words (Russian) +const RUSSIAN_BLACKLIST = [ + 'хуй', 'хуя', 'хуи', 'хуе', 'хую', 'xyй', 'xуй', 'хyй', 'xyи', 'xуи', 'хyи', + 'пизд', 'пизда', 'пиздец', 'пизды', 'пизде', 'пиздой', 'piзд', 'пiзд', + 'ебал', 'ебать', 'ебет', 'ебут', 'ебал', 'ебала', 'ебало', 'еба', + 'бля', 'блять', 'блядь', 'бляд', 'блять', 'блят', 'блядина', 'блядский', + 'сука', 'суки', 'суку', 'сукой', 'cyка', 'cука', 'сyка', + 'гандон', 'гондон', 'пидор', 'пидар', 'педик', 'пидр', 'пидарас', + 'уебок', 'уебан', 'дебил', 'мудак', 'долбоеб', 'еблан', + 'залупа', 'жопа', 'срака', 'говно', 'дерьмо', 'срать', + 'манда', 'мандавошка', 'влагалище', +]; + +// Profanity and inappropriate words (English) +const ENGLISH_BLACKLIST = [ + 'fuck', 'fucked', 'fucker', 'fucking', 'fucks', 'fuk', 'fck', 'f**k', + 'shit', 'shits', 'shitty', 'shitter', 'sh1t', 'sht', + 'bitch', 'bitches', 'bitching', 'b1tch', 'btch', + 'ass', 'asshole', 'arsehole', 'arse', 'a55', 'a55hole', + 'dick', 'dicks', 'dickhead', 'd1ck', 'dik', + 'cock', 'cocks', 'c0ck', 'cok', + 'cunt', 'cunts', 'c**t', 'cnt', + 'pussy', 'pussies', 'puss', + 'whore', 'slut', 'sluts', 'slutty', + 'bastard', 'bastards', 'wanker', 'tosser', + 'nigger', 'nigga', 'negro', 'n1gger', 'n1gga', + 'faggot', 'fag', 'fags', 'fagot', 'f4ggot', + 'retard', 'retarded', 'tard', + 'rape', 'raping', 'rapist', + 'nazi', 'hitler', 'heil', +]; + +// Reserved system usernames +const RESERVED_NAMES = [ + 'admin', 'administrator', 'root', 'system', 'support', + 'moderator', 'mod', 'operator', 'staff', 'team', + 'official', 'help', 'info', 'contact', 'service', + 'user', 'username', 'test', 'demo', 'guest', + 'null', 'undefined', 'unknown', 'anonymous', 'anon', + 'ospab', 'ospabhost', 'hosting', 'server', +]; + +// Common number patterns to avoid (like test123, user1, etc) +const GENERIC_PATTERNS = [ + /^user\d+$/i, + /^test\d*$/i, + /^admin\d+$/i, + /^\d+$/, // Only numbers +]; + +// Combine all blacklists +const COMBINED_BLACKLIST = new Set([ + ...RUSSIAN_BLACKLIST, + ...ENGLISH_BLACKLIST, + ...RESERVED_NAMES, +]); + +export type UsernameValidationResult = { + isValid: boolean; + error?: string; +}; + +/** + * Check if username contains blacklisted words + */ +function containsBlacklistedWord(username: string): boolean { + const lower = username.toLowerCase(); + + // Check exact matches + if (COMBINED_BLACKLIST.has(lower)) { + return true; + } + + // Check if username contains any blacklisted word + for (const word of COMBINED_BLACKLIST) { + if (lower.includes(word)) { + return true; + } + } + + // Check generic patterns + for (const pattern of GENERIC_PATTERNS) { + if (pattern.test(username)) { + return true; + } + } + + return false; +} + +/** + * Validate username + */ +export function validateUsername( + username: string, + locale: 'ru' | 'en' = 'ru' +): UsernameValidationResult { + const trimmed = username.trim(); + + // Check length + if (trimmed.length < 3) { + return { + isValid: false, + error: locale === 'en' + ? 'Username must be at least 3 characters long' + : 'Имя пользователя должно быть не менее 3 символов', + }; + } + + if (trimmed.length > 20) { + return { + isValid: false, + error: locale === 'en' + ? 'Username must be no more than 20 characters' + : 'Имя пользователя должно быть не более 20 символов', + }; + } + + // Check format (alphanumeric, underscore, hyphen) + if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) { + return { + isValid: false, + error: locale === 'en' + ? 'Username can only contain letters, numbers, underscore and hyphen' + : 'Имя пользователя может содержать только буквы, цифры, подчёркивание и дефис', + }; + } + + // Check blacklist + if (containsBlacklistedWord(trimmed)) { + return { + isValid: false, + error: locale === 'en' + ? 'This username is not allowed. Please choose another one.' + : 'Это имя пользователя недопустимо. Пожалуйста, выберите другое.', + }; + } + + return { isValid: true }; +} + +/** + * Get username validation error message + */ +export function getUsernameError( + username: string, + locale: 'ru' | 'en' = 'ru' +): string | null { + const result = validateUsername(username, locale); + return result.error || null; +}