diff --git a/src/Appwrite/Filter/Name.php b/src/Appwrite/Filter/Name.php index 0ee9e5e674..0dd962169d 100644 --- a/src/Appwrite/Filter/Name.php +++ b/src/Appwrite/Filter/Name.php @@ -4,33 +4,6 @@ namespace Appwrite\Filter; class Name implements Filter { - /** - * Homoglyph mapping: visually similar characters mapped to their Latin equivalents. - */ - private const HOMOGLYPHS = [ - // Cyrillic - 'а' => 'a', 'е' => 'e', 'о' => 'o', 'р' => 'p', 'с' => 'c', - 'у' => 'y', 'х' => 'x', 'А' => 'A', 'В' => 'B', 'Е' => 'E', - 'К' => 'K', 'М' => 'M', 'Н' => 'H', 'О' => 'O', 'Р' => 'P', - 'С' => 'C', 'Т' => 'T', 'У' => 'Y', 'Х' => 'X', - // Greek - 'α' => 'a', 'ο' => 'o', 'ε' => 'e', 'Α' => 'A', 'Β' => 'B', - 'Ε' => 'E', 'Η' => 'H', 'Ι' => 'I', 'Κ' => 'K', 'Μ' => 'M', - 'Ν' => 'N', 'Ο' => 'O', 'Ρ' => 'P', 'Τ' => 'T', 'Χ' => 'X', - 'Ζ' => 'Z', 'ν' => 'v', 'τ' => 't', - ]; - - /** - * Scam trigger phrases to block (checked against lowercased input). - */ - private const SCAM_PHRASES = [ - 'giveaway', 'free crypto', 'click here', 'contact me on', - 'send me dm', 'discount', 'promo code', 'limited offer', - 'whatsapp', 'telegram', 'discord', 'free money', 'act now', - 'congratulations you won', 'claim your prize', 'send me a message', - 'dm me', 'text me', 'call me', 'wire transfer', - ]; - private const MAX_LENGTH = 32; public function apply(mixed $input): mixed @@ -39,183 +12,30 @@ class Name implements Filter return $input; } - $input = $this->stripZeroWidthChars($input); - $input = $this->normalizeUnicode($input); - $input = $this->normalizeHomoglyphs($input); - $input = $this->removeEmojis($input); - $input = $this->removeUrls($input); - $input = $this->removeSocialHandles($input); - $input = $this->stripPhoneNumbers($input); - $input = $this->removeEmailAddresses($input); - $input = $this->stripHtmlTags($input); - $input = $this->blockScamPhrases($input); - $input = $this->collapseWhitespace($input); - $input = $this->cutToMaxLength($input); + // Remove HTML tags + $input = \strip_tags($input); - return $input; - } + $words = \explode(' ', $input); + + // Remove emails + $words = \array_filter($words, fn (string $word) => !\str_contains($word, '@') || !\str_contains($word, '.')); + + // Remove URLs + $words = \array_filter($words, fn (string $word) => !\str_starts_with($word, '://') && !\str_starts_with(\strtolower($word), 'www.')); + + // Remove phone numbers + $words = \array_filter($words, function (string $word) { + $digitCount = 0; + for ($i = 0, $len = \strlen($word); $i < $len; $i++) { + if (\ctype_digit($word[$i])) { + $digitCount++; + } + } + return $digitCount < 7; + }); + + $input = \implode(' ', $words); - /** - * Cut to max length. - */ - private function cutToMaxLength(string $input): string - { return \mb_substr($input, 0, self::MAX_LENGTH); } - - /** - * Remove emoji characters. - */ - private function removeEmojis(string $input): string - { - // Covers most emoji ranges including emoticons, symbols, dingbats, flags, etc. - $pattern = '/[\x{1F600}-\x{1F64F}' . // Emoticons - '\x{1F300}-\x{1F5FF}' . // Misc symbols & pictographs - '\x{1F680}-\x{1F6FF}' . // Transport & map - '\x{1F1E0}-\x{1F1FF}' . // Flags - '\x{2600}-\x{26FF}' . // Misc symbols - '\x{2700}-\x{27BF}' . // Dingbats - '\x{FE00}-\x{FE0F}' . // Variation selectors - '\x{1F900}-\x{1F9FF}' . // Supplemental symbols - '\x{1FA00}-\x{1FA6F}' . // Chess symbols - '\x{1FA70}-\x{1FAFF}' . // Symbols extended-A - '\x{200D}' . // Zero-width joiner (used in emoji sequences) - '\x{20E3}' . // Combining enclosing keycap - ']+/u'; - - return \preg_replace($pattern, '', $input) ?? $input; - } - - /** - * Remove URLs (http://, https://, www., and common TLD patterns). - */ - private function removeUrls(string $input): string - { - // Match http(s):// URLs - $input = \preg_replace('#https?://[^\s]+#iu', '', $input) ?? $input; - - // Match www. URLs - $input = \preg_replace('#www\.[^\s]+#iu', '', $input) ?? $input; - - // Match bare domain-like patterns (e.g. "example.com", "scam.link") - $input = \preg_replace('#\b[a-zA-Z0-9\-]+\.(com|org|net|io|co|me|info|link|xyz|click|top|ru|cn|tk|ml|ga|cf|gq|pw|bid|win)\b#iu', '', $input) ?? $input; - - return $input; - } - - /** - * Remove social media handles (@username, #hashtag). - */ - private function removeSocialHandles(string $input): string - { - // Remove @mentions - $input = \preg_replace('/@[\w.]{1,30}/u', '', $input) ?? $input; - - // Remove #hashtags - $input = \preg_replace('/#[\w]{1,50}/u', '', $input) ?? $input; - - return $input; - } - - /** - * Strip phone numbers and phone-like patterns. - */ - private function stripPhoneNumbers(string $input): string - { - // Match international format: +1 234 567 8900, +44-20-7946-0958, etc. - $input = \preg_replace('/\+?\d[\d\s\-\.\(\)]{6,}\d/u', '', $input) ?? $input; - - return $input; - } - - /** - * Remove email addresses. - */ - private function removeEmailAddresses(string $input): string - { - $input = \preg_replace('/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/u', '', $input) ?? $input; - - return $input; - } - - /** - * Strip HTML tags and elements. - */ - private function stripHtmlTags(string $input): string - { - return \strip_tags($input); - } - - /** - * Strip zero-width and invisible Unicode characters. - */ - private function stripZeroWidthChars(string $input): string - { - $pattern = '/[\x{200B}-\x{200F}' . // Zero-width space, non-joiner, joiner, LTR/RTL marks - '\x{2028}-\x{202F}' . // Line/paragraph separators, embedding controls - '\x{2060}-\x{2064}' . // Word joiner, invisible separators - '\x{FEFF}' . // BOM / zero-width no-break space - '\x{00AD}' . // Soft hyphen - '\x{034F}' . // Combining grapheme joiner - '\x{061C}' . // Arabic letter mark - '\x{180E}' . // Mongolian vowel separator - ']+/u'; - - return \preg_replace($pattern, '', $input) ?? $input; - } - - /** - * Normalize Unicode using NFKC normalization. - * Converts compatibility characters to their canonical equivalents. - */ - private function normalizeUnicode(string $input): string - { - if (\function_exists('normalizer_normalize')) { - return \Normalizer::normalize($input, \Normalizer::FORM_KC) ?: $input; - } - - return $input; - } - - /** - * Normalize homoglyph/lookalike characters to Latin equivalents. - */ - private function normalizeHomoglyphs(string $input): string - { - return \str_replace( - \array_keys(self::HOMOGLYPHS), - \array_values(self::HOMOGLYPHS), - $input - ); - } - - /** - * Block known scam trigger phrases by removing them. - */ - private function blockScamPhrases(string $input): string - { - $lower = \mb_strtolower($input); - - foreach (self::SCAM_PHRASES as $phrase) { - $pos = \mb_strpos($lower, $phrase); - while ($pos !== false) { - $len = \mb_strlen($phrase); - $input = \mb_substr($input, 0, $pos) . \mb_substr($input, $pos + $len); - $lower = \mb_substr($lower, 0, $pos) . \mb_substr($lower, $pos + $len); - $pos = \mb_strpos($lower, $phrase, $pos); - } - } - - return $input; - } - - /** - * Collapse repeated whitespace and trim. - */ - private function collapseWhitespace(string $input): string - { - $input = \preg_replace('/\s+/u', ' ', $input) ?? $input; - - return \trim($input); - } } diff --git a/tests/e2e/Services/Account/AccountBase.php b/tests/e2e/Services/Account/AccountBase.php index a81da60968..7e4aaa5fc5 100644 --- a/tests/e2e/Services/Account/AccountBase.php +++ b/tests/e2e/Services/Account/AccountBase.php @@ -142,6 +142,48 @@ trait AccountBase ]; } + public function testCreateAccountNameFilter(): void + { + $headers = array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + ]); + + // Name with email gets stripped + $response = $this->client->call(Client::METHOD_POST, '/account', $headers, [ + 'userId' => ID::unique(), + 'email' => uniqid() . 'filter1@localhost.test', + 'password' => 'password', + 'name' => 'John user@example.com', + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals('John', $response['body']['name']); + + // Name with phone number gets stripped + $response = $this->client->call(Client::METHOD_POST, '/account', $headers, [ + 'userId' => ID::unique(), + 'email' => uniqid() . 'filter2@localhost.test', + 'password' => 'password', + 'name' => 'John +1234567890', + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals('John', $response['body']['name']); + + // Name with URL gets stripped + $response = $this->client->call(Client::METHOD_POST, '/account', $headers, [ + 'userId' => ID::unique(), + 'email' => uniqid() . 'filter3@localhost.test', + 'password' => 'password', + 'name' => 'John https://example.com', + ]); + + $this->assertEquals(201, $response['headers']['status-code']); + $this->assertEquals('John', $response['body']['name']); + } + public function testEmailOTPSession(): void { $isConsoleProject = $this->getProject()['$id'] === 'console'; diff --git a/tests/unit/Filter/NameTest.php b/tests/unit/Filter/NameTest.php new file mode 100644 index 0000000000..26e33a406a --- /dev/null +++ b/tests/unit/Filter/NameTest.php @@ -0,0 +1,78 @@ +filter = new Name(); + } + + public function testNonStringInput(): void + { + $this->assertSame(123, $this->filter->apply(123)); + $this->assertSame(null, $this->filter->apply(null)); + $this->assertSame(true, $this->filter->apply(true)); + $this->assertSame([], $this->filter->apply([])); + } + + public function testPlainName(): void + { + $this->assertSame('John Doe', $this->filter->apply('John Doe')); + } + + public function testHtmlTags(): void + { + $this->assertSame('John Doe', $this->filter->apply('John Doe')); + $this->assertSame('Hello', $this->filter->apply('Hello')); + } + + public function testEmails(): void + { + $this->assertSame('John', $this->filter->apply('John user@example.com')); + $this->assertSame('John Doe', $this->filter->apply('John test@mail.org Doe')); + } + + public function testUrls(): void + { + $this->assertSame('John', $this->filter->apply('John http://example.com')); + $this->assertSame('John', $this->filter->apply('John https://example.com')); + $this->assertSame('Visit', $this->filter->apply('Visit www.example.com')); + $this->assertSame('Visit', $this->filter->apply('Visit WWW.EXAMPLE.COM')); + } + + public function testPhoneNumbers(): void + { + $this->assertSame('John', $this->filter->apply('John 1234567')); + $this->assertSame('John', $this->filter->apply('John +1-234-567-8901')); + $this->assertSame('Call', $this->filter->apply('Call 555-123-4567')); + } + + public function testShortNumbersKept(): void + { + $this->assertSame('Agent 007', $this->filter->apply('Agent 007')); + $this->assertSame('Room 404', $this->filter->apply('Room 404')); + } + + public function testMaxLength(): void + { + $long = str_repeat('A', 50); + $this->assertSame(str_repeat('A', 32), $this->filter->apply($long)); + } + + public function testCombined(): void + { + $this->assertSame('John', $this->filter->apply('John user@example.com https://evil.com +1234567890')); + } + + public function testEmptyString(): void + { + $this->assertSame('', $this->filter->apply('')); + } +}