Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions app/Http/Controllers/ChatController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 16 additions & 9 deletions app/Services/ChatProfanityFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Expand Down
45 changes: 45 additions & 0 deletions tests/Feature/ChatAutoBanTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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]',
]);
});