Advanced registration validation: email domain check, username blacklist, MX verification
This commit is contained in:
@@ -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<string | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
const [emailError, setEmailError] = useState<string | null>(null);
|
||||
const [emailSuggestion, setEmailSuggestion] = useState<string | null>(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 = () => {
|
||||
<div className="bg-white p-8 md:p-10 rounded-3xl shadow-2xl w-full max-w-md text-center">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">{t('auth.register.title')}</h1>
|
||||
<form onSubmit={handleRegister}>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
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 && (
|
||||
<p className="mt-1 text-sm text-red-500 text-left px-3">{usernameError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative mb-4">
|
||||
<input
|
||||
type="email"
|
||||
@@ -148,13 +214,22 @@ const RegisterPage = () => {
|
||||
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 && (
|
||||
<p className="mt-1 text-sm text-yellow-600 text-left px-3">
|
||||
{locale === 'en' ? 'Verifying email...' : 'Проверка email...'}
|
||||
</p>
|
||||
)}
|
||||
{emailError && (
|
||||
<p className="mt-1 text-sm text-red-500 text-left px-3">{emailError}</p>
|
||||
)}
|
||||
{emailSuggestion && (
|
||||
{emailSuggestion && !emailError && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={applySuggestion}
|
||||
@@ -190,14 +265,16 @@ const RegisterPage = () => {
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !turnstileToken || !!emailError}
|
||||
disabled={isLoading || !turnstileToken || !!emailError || !!usernameError || isVerifyingEmail}
|
||||
className="w-full px-5 py-3 rounded-full text-white font-bold transition-colors transform hover:scale-105 bg-ospab-primary hover:bg-ospab-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? t('auth.register.loading') : t('auth.register.submit')}
|
||||
</button>
|
||||
</form>
|
||||
{error && (
|
||||
<p className="mt-4 text-sm text-red-500">{error}</p>
|
||||
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Social networks */}
|
||||
@@ -211,38 +288,32 @@ const RegisterPage = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="mt-6 grid grid-cols-3 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin('google')}
|
||||
className="w-full flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg shadow-sm bg-white text-xs sm:text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
|
||||
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
|
||||
aria-label={locale === 'en' ? 'Sign up with Google' : 'Регистрация через Google'}
|
||||
>
|
||||
<img src="/google.png" alt="Google" className="h-5 w-5 mr-1 sm:mr-2 flex-shrink-0" />
|
||||
<span className="truncate">Google</span>
|
||||
<img src="/google.png" alt="Google" className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin('github')}
|
||||
className="w-full flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg shadow-sm bg-white text-xs sm:text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
|
||||
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
|
||||
aria-label={locale === 'en' ? 'Sign up with GitHub' : 'Регистрация через GitHub'}
|
||||
>
|
||||
<img src="/github.png" alt="GitHub" className="h-5 w-5 mr-1 sm:mr-2 flex-shrink-0" />
|
||||
<span className="truncate">GitHub</span>
|
||||
<img src="/github.png" alt="GitHub" className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin('yandex')}
|
||||
className="w-full flex items-center justify-center px-3 py-2 border border-gray-300 rounded-lg shadow-sm bg-white text-xs sm:text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
|
||||
className="flex items-center justify-center h-12 rounded-full border border-gray-300 bg-white shadow-sm hover:bg-gray-50 transition"
|
||||
aria-label={locale === 'en' ? 'Sign up with Yandex' : 'Регистрация через Yandex'}
|
||||
>
|
||||
<svg className="h-5 w-5 mr-1 sm:mr-2 flex-shrink-0" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<rect x="4" y="4" width="40" height="40" rx="12" fill="#000000" />
|
||||
<path
|
||||
d="M25.92 11.5h-5.04c-6.16 0-9.18 2.8-9.18 8.56v3.54c0 3.36.92 5.56 2.72 6.94l7.56 6.96h3.76l-6.08-8.8c-1.32-1.08-1.96-2.9-1.96-5.6v-3.54c0-4.08 1.82-5.84 5.62-5.84h4.06c3.8 0 5.62 1.76 5.62 5.84v2.1h3.16v-2.1c0-5.76-3.08-8.56-9.24-8.56z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
</svg>
|
||||
<span className="truncate">Yandex</span>
|
||||
<img src="/yandex.png" alt="" className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
174
ospabhost/frontend/src/utils/emailVerification.ts
Normal file
174
ospabhost/frontend/src/utils/emailVerification.ts
Normal file
@@ -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<EmailAPIResult> {
|
||||
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<EmailAPIResult> {
|
||||
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<boolean> {
|
||||
// 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<EmailAPIResult> {
|
||||
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 };
|
||||
}
|
||||
156
ospabhost/frontend/src/utils/usernameBlacklist.ts
Normal file
156
ospabhost/frontend/src/utils/usernameBlacklist.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user