PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.11.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.11.0
2.11.0 2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 All 78 releases
← All changes | app/Http/Controllers/CommentsController.php +107 -47 2.4.012.11.0 View file →
@@ -11,8 +11,9 @@
11 11 use FluentCommunity\Framework\Http\Request\Request;
12 12 use FluentCommunity\App\Models\Comment;
13 13 use FluentCommunity\App\Models\Feed;
14 14 use FluentCommunity\App\Models\Reaction;
15 +use FluentCommunity\App\Models\XProfile;
15 16 use FluentCommunity\Framework\Support\Arr;
16 17
17 18 class CommentsController extends Controller
18 19 {
@@ -17,11 +18,29 @@
17 18 class CommentsController extends Controller
18 19 {
19 20 public function getComments(Request $request, $feed_id)
20 21 {
21 - $feed = Feed::withoutGlobalScopes()->findOrFail($feed_id);
22 - $canViewComments = apply_filters('fluent_community/can_view_comments_' . $feed->type, true, $feed);
22 + $feed = Feed::withoutGlobalScopes()
23 + ->byUserAccess(get_current_user_id())
24 + ->findOrFail($feed_id);
23 25
26 + if (!in_array($feed->status, FeedsHelper::getViewableByLinkStatuses(), true) && !$feed->hasEditAccess($this->getUserId())) {
27 + return $this->sendError([
28 + 'message' => __('Sorry, you do not have permission to view this post', 'fluent-community')
29 + ], 404);
30 + }
31 +
32 + /*
33 + * The row's own setting is the default the filter gets handed, rather than a bare
34 + * true. Before this, meta.enable_comments was read nowhere on this path, so a page
35 + * with comments switched off still served its thread to anyone who asked for it.
36 + */
37 + $canViewComments = apply_filters(
38 + 'fluent_community/can_view_comments_' . $feed->type,
39 + FeedsHelper::commentsEnabled($feed),
40 + $feed
41 + );
42 +
24 43 if (!$canViewComments) {
25 44 return [
26 45 'comments' => []
27 46 ];
@@ -69,9 +88,9 @@
69 88
70 89 $text = $this->validateCommentText($request->all());
71 90 $feed = Feed::withoutGlobalScopes()->findOrFail($feedId);
72 91
73 - if ($feed->status != 'published') {
92 + if (!in_array($feed->status, FeedsHelper::getViewableByLinkStatuses(), true)) {
74 93 return $this->sendError([
75 94 'message' => __('This post is not published yet', 'fluent-community')
76 95 ]);
77 96 }
@@ -79,25 +98,8 @@
79 98 $this->verifyCreateCommentPermission($feed);
80 99
81 100 $requestData = $request->all();
82 101
83 - // Check for duplicate (only for comments with text)
84 - if ($text) {
85 - $skipDuplicateCheck = apply_filters('fluent_community/disable_duplicate_comment_check', false, get_current_user_id(), $feed->id);
86 - if (!$skipDuplicateCheck) {
87 - $exist = Comment::where('user_id', get_current_user_id())
88 - ->where('message', $text)
89 - ->where('post_id', $feed->id)
90 - ->first();
91 -
92 - if ($exist) {
93 - return $this->sendError([
94 - 'message' => __('No duplicate comment please!', 'fluent-community')
95 - ]);
96 - }
97 - }
98 - }
99 -
100 102 [$markdown, $inlineMedias] = FeedsHelper::replaceImageUrlsWithRealMediaArchive($text);
101 103 $mentions = FeedsHelper::getMentions($markdown, $feed->space_id, true);
102 104 $commentHtml = $this->generateCommentHtml($markdown, $mentions);
103 105
@@ -129,14 +131,41 @@
129 131 do_action('fluent_community/before_comment_create', $commentData, $feed);
130 132
131 133 $commentData = apply_filters('fluent_community/comment/comment_data', $commentData, $feed);
132 134
133 - $comment = Comment::create($commentData);
135 + // Only comments with text are duplicate checked
136 + $shouldCheckDuplicate = $text && !apply_filters('fluent_community/disable_duplicate_comment_check', false, get_current_user_id(), $feed->id);
134 137
135 - $feed->comments_count = $feed->comments_count + 1;
136 - $feed->save();
138 + // Serialize a member's concurrent submissions by locking their profile row,
139 + // so parallel matching requests cannot pass the duplicate check and both insert.
140 + $comment = Helper::dbTransaction(function () use ($commentData, $feed, $text, $shouldCheckDuplicate) {
141 + XProfile::where('user_id', get_current_user_id())->lockForUpdate()->first();
137 142
143 + if ($shouldCheckDuplicate && Comment::where('user_id', get_current_user_id())->where('message', $text)->where('post_id', $feed->id)->first()) {
144 + return null;
145 + }
138 146
147 + $newComment = Comment::create($commentData);
148 +
149 + // A held comment is not visible yet, so it must not be counted until it is approved.
150 + if ($newComment->status === 'published') {
151 + Feed::withoutGlobalScopes()->where('id', $feed->id)->increment('comments_count');
152 + }
153 +
154 + return $newComment;
155 + });
156 +
157 + if (!$comment) {
158 + return $this->sendError([
159 + 'message' => __('No duplicate comment please!', 'fluent-community')
160 + ]);
161 + }
162 +
163 + if ($comment->status === 'published') {
164 + $feed->comments_count = $feed->comments_count + 1;
165 + }
166 +
167 +
139 168 // Merge and save all media in one loop
140 169 $mediaItems = $mediaItems ? (is_array($mediaItems) ? $mediaItems : [$mediaItems]) : [];
141 170
142 171 if ($inlineMedias) {
@@ -160,12 +189,13 @@
160 189 if ($comment->status != 'published') {
161 190 do_action('fluent_community/comment/new_comment_' . $comment->status, $comment, $feed);
162 191 /* translators: %$s is replaced by the status of the comment */
163 192 $message = sprintf(__('Your comment has been marked as %s', 'fluent-community'), $comment->status);
164 - return [
193 + $response = [
165 194 'comment' => $comment,
166 195 'message' => $message
167 196 ];
197 + return apply_filters('fluent_community/comment/new_comment_response', $response, $comment);
168 198 }
169 199
170 200 do_action('fluent_community/comment_added_' . $feed->type, $comment, $feed);
171 201 do_action('fluent_community/comment_added', $comment, $feed, Arr::get($mentions, 'users', []));
@@ -184,8 +214,15 @@
184 214 $this->verifySpacePermission($feed);
185 215
186 216 $requestData = $request->all();
187 217 $comment = Comment::findOrFail($commentId);
218 +
219 + if ($comment->post_id != $feed->id) {
220 + return $this->sendError([
221 + 'message' => __('Invalid comment', 'fluent-community')
222 + ]);
223 + }
224 +
188 225 $user = $this->getUser(true);
189 226
190 227 $requestData['is_admin'] = $user->hasPermissionOrInCurrentSpace('community_moderator', $feed->space);
191 228
@@ -267,8 +304,14 @@
267 304 $feed = Feed::withoutGlobalScopes()->findOrFail($feedId);
268 305
269 306 $comment = Comment::findOrFail($commentId);
270 307
308 + if ($comment->post_id != $feed->id) {
309 + return $this->sendError([
310 + 'message' => __('Invalid comment', 'fluent-community')
311 + ]);
312 + }
313 +
271 314 $user = $this->getUser(true);
272 315
273 316 $isMod = $user && $user->hasPermissionOrInCurrentSpace('community_moderator', $feed->space);
274 317 $isAdmin = $user && $user->hasPermissionOrInCurrentSpace('community_admin', $feed->space);
@@ -387,14 +430,17 @@
387 430 return [$commentData, [$existingMedia]];
388 431 }
389 432 }
390 433
434 + // type/provider reach :class bindings and width/height a :style binding in
435 + // _MediaPreview.vue. Neither is an executable sink, but the stored values are
436 + // request-supplied so they are normalised here rather than trusted.
391 437 $commentData['meta']['media_preview'] = array_filter([
392 438 'image' => sanitize_url(Arr::get($requestData, 'meta.media_preview.image', '')),
393 - 'type' => Arr::get($requestData, 'meta.media_preview.type', 'image'),
394 - 'provider' => Arr::get($requestData, 'meta.media_preview.provider', ''),
395 - 'height' => Arr::get($requestData, 'meta.media_preview.height', 0),
396 - 'width' => Arr::get($requestData, 'meta.media_preview.width', 0),
439 + 'type' => sanitize_text_field(Arr::get($requestData, 'meta.media_preview.type', 'image')),
440 + 'provider' => sanitize_text_field(Arr::get($requestData, 'meta.media_preview.provider', '')),
441 + 'height' => (int) Arr::get($requestData, 'meta.media_preview.height', 0),
442 + 'width' => (int) Arr::get($requestData, 'meta.media_preview.width', 0),
397 443 ]);
398 444
399 445 return [$commentData, []];
400 446 }
@@ -400,9 +446,9 @@
400 446 }
401 447
402 448 private function validateCommentText($data)
403 449 {
404 - $text = trim(Arr::get($data, 'comment'));
450 + $text = trim((string) Arr::get($data, 'comment', ''));
405 451 $text = CustomSanitizer::unslashMarkdown($text);
406 452
407 453 // Decode HTML entities (e.g.,   for space) and strip all whitespace for validation
408 454 $textForValidation = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
@@ -420,9 +466,10 @@
420 466 }
421 467
422 468 $maxCommentLength = apply_filters('fluent_community/max_comment_char_length', 10000);
423 469 if ($text && strlen($text) > $maxCommentLength) {
424 - throw new \Exception(esc_html__('Comment text is too long', 'fluent-community'), 422);
470 + /* translators: %s is the maximum allowed character count */
471 + throw new \Exception(esc_html(sprintf(__('The comment is too long. Please keep it under %s characters.', 'fluent-community'), number_format($maxCommentLength))), 422);
425 472 }
426 473
427 474 return $text;
428 475 }
@@ -428,9 +475,9 @@
428 475 }
429 476
430 477 private function verifyCreateCommentPermission($feed)
431 478 {
432 - if (Arr::get($feed->meta, 'comments_disabled') === 'yes') {
479 + if (!FeedsHelper::commentsEnabled($feed)) {
433 480 throw new \Exception(esc_html__('Comments are disabled for this post', 'fluent-community'));
434 481 }
435 482
436 483 $this->verifySpacePermission($feed);
@@ -479,17 +526,18 @@
479 526 {
480 527 $userId = get_current_user_id();
481 528 $feed = Feed::withoutGlobalScopes()->byUserAccess($userId)->findOrFail($feed_id);
482 529 $type = $request->get('react_type', 'like');
530 + $type = in_array($type, ['like', 'bookmark'], true) ? $type : 'like';
483 531 $willRemove = $request->get('remove');
484 532
485 - if ($feed->status != 'published') {
533 + if (!in_array($feed->status, FeedsHelper::getViewableByLinkStatuses(), true)) {
486 534 return $this->sendError([
487 535 'message' => __('This post is not published yet', 'fluent-community')
488 536 ]);
489 537 }
490 538
491 - if ($userId === $feed->user_id && apply_filters('fluent_community/disable_self_post_react', false, $feed)) {
539 + if (!$willRemove && (int) $userId === (int) $feed->user_id && apply_filters('fluent_community/disable_self_post_react', false, $feed)) {
492 540 return $this->sendError([
493 541 'message' => __('You cannot react to your own post', 'fluent-community')
494 542 ]);
495 543 }
@@ -571,9 +619,11 @@
571 619 }
572 620
573 621 $comment->delete();
574 622
575 - $feed->comments_count = Comment::where('post_id', $feed->id)->count();
623 + $feed->comments_count = Comment::where('post_id', $feed->id)
624 + ->where('status', 'published')
625 + ->count();
576 626 $feed->timestamps = false; // Don't update the updated_at timestamp
577 627 $feed->save();
578 628
579 629 do_action('fluent_community/comment_deleted_' . $feed->type, $commentId, $feed);
@@ -585,9 +635,9 @@
585 635 }
586 636
587 637 public function toggleReaction(Request $request, $feedId, $commentId)
588 638 {
589 - $feed = Feed::withoutGlobalScopes()->findOrFail($feedId);
639 + $feed = Feed::withoutGlobalScopes()->byUserAccess(get_current_user_id())->findOrFail($feedId);
590 640 $comment = Comment::findOrFail($commentId);
591 641
592 642 if ($comment->post_id != $feed->id) {
593 643 return $this->sendError([
@@ -601,28 +651,38 @@
601 651 $user->verifySpacePermission('registered', $feed->space);
602 652 }
603 653
604 654 $userId = get_current_user_id();
605 - if ($userId === $comment->user_id && apply_filters('fluent_community/disable_self_comment_react', false, $feed)) {
655 + $reactionState = !!$request->get('state', false);
656 +
657 + if ($reactionState && (int) $userId === (int) $comment->user_id && apply_filters('fluent_community/disable_self_comment_react', false, $feed)) {
606 658 return $this->sendError([
607 659 'message' => __('You cannot react to your own comment', 'fluent-community')
608 660 ]);
609 661 }
610 662
611 - $reactionState = !!$request->get('state', false);
663 + if ($reactionState) {
664 + // Serialize concurrent reactions on this comment by locking its row,
665 + // so parallel add requests cannot each insert a duplicate reaction.
666 + $reaction = Helper::dbTransaction(function () use ($comment, $feed) {
667 + XProfile::where('user_id', get_current_user_id())->lockForUpdate()->first();
612 668
613 - if ($reactionState) {
614 - // add or update the reaction
615 - $reaction = Reaction::firstOrCreate([
616 - 'user_id' => get_current_user_id(),
617 - 'object_id' => $comment->id,
618 - 'object_type' => 'comment',
619 - 'parent_id' => $feed->id
620 - ]);
669 + $reaction = Reaction::firstOrCreate([
670 + 'user_id' => get_current_user_id(),
671 + 'object_id' => $comment->id,
672 + 'object_type' => 'comment',
673 + 'parent_id' => $feed->id
674 + ]);
621 675
676 + if ($reaction->wasRecentlyCreated) {
677 + Comment::where('id', $comment->id)->increment('reactions_count');
678 + $comment->reactions_count = $comment->reactions_count + 1;
679 + }
680 +
681 + return $reaction;
682 + });
683 +
622 684 if ($reaction->wasRecentlyCreated) {
623 - $comment->reactions_count = $comment->reactions_count + 1;
624 - $comment->save();
625 685 do_action('fluent_community/comment/react_added', $reaction, $comment, $feed);
626 686 }
627 687 } else {
628 688 // remove the reaction