diff --git a/app/Http/Controllers/ChatController.php b/app/Http/Controllers/ChatController.php index 336fc5c2..37f94afc 100644 --- a/app/Http/Controllers/ChatController.php +++ b/app/Http/Controllers/ChatController.php @@ -176,9 +176,7 @@ public function store(Request $request) // Check for abusive / prohibited language if (ChatProfanityFilter::hasProfanity($content)) { - return response()->json([ - 'message' => 'Your message contains inappropriate or prohibited language. If you try again, you may be temporarily banned from chat.', - ], 422); + $content = '[Message hidden for inappropriate language]'; } // Prevent duplicate message sent twice in a streak by the same user diff --git a/app/Services/ChatProfanityFilter.php b/app/Services/ChatProfanityFilter.php index f93d054a..948b5dc1 100644 --- a/app/Services/ChatProfanityFilter.php +++ b/app/Services/ChatProfanityFilter.php @@ -24,23 +24,30 @@ public static function hasProfanity(string $text): bool $normalized = self::normalize($text); + // 1. Remove punctuation separators within words (e.g. "f.u.c.k", "s_e_x", "a-s-s") + $strippedPunctuation = preg_replace('/(?<=\p{L})[._\-*~]+(?=\p{L})/u', '', $normalized); + + // 2. Collapse spaced-out single letters (e.g. "f u c k" -> "fuck", "a s s" -> "ass") + $collapsedSpaces = preg_replace_callback('/\b(?:\p{L}\s+){2,}\p{L}\b/u', function ($m) { + return preg_replace('/\s+/u', '', $m[0]); + }, $strippedPunctuation); + + $variants = [$text, $normalized, $strippedPunctuation, $collapsedSpaces]; + foreach ($bannedWords as $word) { $word = trim(mb_strtolower($word)); if ($word === '') { continue; } - // Exact word boundary matching or normalized substring matching $escaped = preg_quote($word, '/'); - if (preg_match("/\b{$escaped}\b/ui", $text) || preg_match("/\b{$escaped}\b/ui", $normalized)) { - return true; - } + // Unicode-safe word boundary pattern to prevent substring false-positives (e.g. "ass" in "assalamualaikum") + $pattern = '/(?<=^|[^\p{L}\p{N}])'.$escaped.'(?=[^\p{L}\p{N}]|$)/ui'; - // Compact match without spaces or special symbols (e.g. f.u.c.k or f_u_c_k) - $compactText = preg_replace('/[^\p{L}\p{N}]/u', '', $normalized); - $compactWord = preg_replace('/[^\p{L}\p{N}]/u', '', $word); - if ($compactWord !== '' && str_contains($compactText, $compactWord)) { - return true; + foreach ($variants as $variant) { + if (preg_match($pattern, $variant)) { + return true; + } } } diff --git a/tests/Feature/ChatAutoBanTest.php b/tests/Feature/ChatAutoBanTest.php index d72deffe..90404341 100644 --- a/tests/Feature/ChatAutoBanTest.php +++ b/tests/Feature/ChatAutoBanTest.php @@ -2,6 +2,7 @@ use App\Models\AppSetting; use App\Models\User; +use App\Services\ChatProfanityFilter; use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Role; @@ -110,3 +111,47 @@ expect($targetUser->fresh()->isChatBanned())->toBeFalse(); }); + +test('profanity filter allows legitimate words containing substrings while blocking actual banned words and obfuscations', function () { + AppSetting::set('global_chat_profanity_filter_enabled', true, 'boolean'); + AppSetting::set('global_chat_banned_words', 'badword, toxic, spammer'); + + // Legitimate words containing substrings must NOT be blocked + expect(ChatProfanityFilter::hasProfanity('assalamualaikum'))->toBeFalse(); + expect(ChatProfanityFilter::hasProfanity('assalamualaikum brothers'))->toBeFalse(); + expect(ChatProfanityFilter::hasProfanity('walaikumsalam'))->toBeFalse(); + expect(ChatProfanityFilter::hasProfanity('class assignment'))->toBeFalse(); + expect(ChatProfanityFilter::hasProfanity('compass assistant'))->toBeFalse(); + expect(ChatProfanityFilter::hasProfanity('classic physics'))->toBeFalse(); + expect(ChatProfanityFilter::hasProfanity('password passage'))->toBeFalse(); + + // Actual banned words and obfuscations MUST be blocked + expect(ChatProfanityFilter::hasProfanity('you are badword'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('you are b.a.d.w.o.r.d'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('you are b a d w o r d'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('you are b@dw0rd'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('t.o.x.i.c user'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('t o x i c user'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('t_o_x_i_c'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('t0x1c'))->toBeTrue(); + expect(ChatProfanityFilter::hasProfanity('tooooxic'))->toBeTrue(); +}); + +test('chat messages replace profanity with system notice instead of blocking the user', function () { + AppSetting::set('global_chat_enabled', true, 'boolean'); + AppSetting::set('global_chat_audience', 'all'); + AppSetting::set('global_chat_profanity_filter_enabled', true, 'boolean'); + AppSetting::set('global_chat_banned_words', 'badword, toxic'); + + $user = User::factory()->create(['username' => 'chat_sender']); + + $response = $this->actingAs($user)->postJson(route('chat.messages.store'), [ + 'content' => 'What is this badword', + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('chat_messages', [ + 'user_id' => $user->id, + 'content' => '[Message hidden for inappropriate language]', + ]); +});