diff --git a/src/CommentsServiceProvider.php b/src/CommentsServiceProvider.php index 66ade45..2418923 100644 --- a/src/CommentsServiceProvider.php +++ b/src/CommentsServiceProvider.php @@ -18,6 +18,8 @@ use Relaticle\Comments\Livewire\Comments; use Relaticle\Comments\Livewire\Reactions; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; +use Symfony\Component\HtmlSanitizer\HtmlSanitizer; +use Symfony\Component\HtmlSanitizer\HtmlSanitizerConfig; class CommentsServiceProvider extends PackageServiceProvider { @@ -51,6 +53,27 @@ class CommentsServiceProvider extends PackageServiceProvider MentionResolver::class, fn () => new (CommentsConfig::getMentionResolver()) ); + + $this->app->scoped( + 'comments.html_sanitizer', + fn (): HtmlSanitizer => new HtmlSanitizer( + (new HtmlSanitizerConfig) + ->allowSafeElements() + ->allowRelativeLinks() + ->allowRelativeMedias() + ->allowAttribute('class', allowedElements: '*') + ->allowAttribute('data-color', allowedElements: '*') + ->allowAttribute('data-from-breakpoint', allowedElements: '*') + ->allowAttribute('data-type', allowedElements: '*') + ->allowAttribute('data-id', allowedElements: 'span') + ->allowAttribute('data-label', allowedElements: 'span') + ->allowAttribute('data-char', allowedElements: 'span') + ->allowAttribute('style', allowedElements: '*') + ->allowAttribute('width', allowedElements: 'img') + ->allowAttribute('height', allowedElements: 'img') + ->withMaxInputLength(500000) + ), + ); } public function packageBooted(): void diff --git a/src/Models/Comment.php b/src/Models/Comment.php index 78ec451..a03f50a 100644 --- a/src/Models/Comment.php +++ b/src/Models/Comment.php @@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\MorphToMany; use Illuminate\Database\Eloquent\SoftDeletes; -use Illuminate\Support\Str; use Relaticle\Comments\CommentsConfig; use Relaticle\Comments\Database\Factories\CommentFactory; @@ -23,7 +22,7 @@ class Comment extends Model parent::boot(); static::saving(function (self $comment): void { - $comment->body = Str::sanitizeHtml($comment->body); + $comment->body = app('comments.html_sanitizer')->sanitize($comment->body); }); static::deleting(function (self $comment): void { @@ -153,16 +152,15 @@ class Comment extends Model $escapedName = e($name); $styledSpan = '@'.$escapedName.''; - // Replace rich-editor mention spans (data-type="mention" with @Name as text content) - $body = preg_replace( - '/<(?:span|a)[^>]*data-type="mention"[^>]*>@?' . preg_quote($escapedName, '/') . '<\/(?:span|a)>/', - $styledSpan, - $body - ); + $pattern = '/<(?:span|a)[^>]*data-type="mention"[^>]*>@?' . preg_quote($escapedName, '/') . '<\/(?:span|a)>/'; - // Replace plain-text mentions - $body = str_replace("@{$name}", $styledSpan, $body); - $body = str_replace("@{$name}", $styledSpan, $body); + if (preg_match($pattern, $body)) { + $body = preg_replace($pattern, $styledSpan, $body); + } else { + // Fallback for plain-text mentions + $body = str_replace("@{$name}", $styledSpan, $body); + $body = str_replace("@{$name}", $styledSpan, $body); + } } return $body; diff --git a/tests/Feature/ContentSanitizationTest.php b/tests/Feature/ContentSanitizationTest.php index e49a302..e855d62 100644 --- a/tests/Feature/ContentSanitizationTest.php +++ b/tests/Feature/ContentSanitizationTest.php @@ -155,6 +155,26 @@ it('strips onclick handler from elements', function () { expect($comment->body)->toContain('click me'); }); +it('preserves mention data attributes in comment body', function () { + $user = User::factory()->create(); + $post = Post::factory()->create(); + + $body = '@max'; + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'commenter_id' => $user->getKey(), + 'commenter_type' => $user->getMorphClass(), + 'body' => $body, + ]); + + expect($comment->body)->toContain('data-type="mention"'); + expect($comment->body)->toContain('data-id="1"'); + expect($comment->body)->toContain('data-label="max"'); + expect($comment->body)->toContain('data-char="@"'); +}); + it('sanitizes content submitted through livewire component', function () { $user = User::factory()->create(); $post = Post::factory()->create(); diff --git a/tests/Feature/MentionDisplayTest.php b/tests/Feature/MentionDisplayTest.php index fe96f86..c20124f 100644 --- a/tests/Feature/MentionDisplayTest.php +++ b/tests/Feature/MentionDisplayTest.php @@ -51,6 +51,28 @@ it('renders multiple mentions with styled spans', function () { expect($rendered)->toContain('comment-mention'); }); +it('renders rich-editor mention span as styled mention', function () { + $user = User::factory()->create(); + $alice = User::factory()->create(['name' => 'Alice']); + $post = Post::factory()->create(); + + $comment = Comment::factory()->create([ + 'commentable_id' => $post->id, + 'commentable_type' => $post->getMorphClass(), + 'commenter_id' => $user->getKey(), + 'commenter_type' => $user->getMorphClass(), + 'body' => '
@Alice said hi
', + ]); + + $comment->mentions()->attach($alice->id, ['commenter_type' => $alice->getMorphClass()]); + + $rendered = $comment->renderBodyWithMentions(); + + expect($rendered)->toContain('comment-mention'); + expect($rendered)->toContain('@Alice'); + expect($rendered)->not->toContain('data-type="mention"'); +}); + it('does not style non-mentioned @text', function () { $user = User::factory()->create(); $post = Post::factory()->create(); diff --git a/tests/Feature/MentionParserTest.php b/tests/Feature/MentionParserTest.php index ee7c01a..9fa23fc 100644 --- a/tests/Feature/MentionParserTest.php +++ b/tests/Feature/MentionParserTest.php @@ -10,6 +10,17 @@ use Relaticle\Comments\Models\Comment; use Relaticle\Comments\Tests\Models\Post; use Relaticle\Comments\Tests\Models\User; +it('parses rich-editor mention spans using data-id', function () { + $john = User::factory()->create(['name' => 'john']); + + $parser = app(MentionParser::class); + $body = 'Hello @john
'; + $result = $parser->parse($body); + + expect($result)->toHaveCount(1); + expect($result->first())->toBe($john->id); +}); + it('parses @username from plain text body', function () { User::factory()->create(['name' => 'john']); User::factory()->create(['name' => 'jane']);