byUserAccess(get_current_user_id()) ->findOrFail($feed_id); if (!in_array($feed->status, FeedsHelper::getViewableByLinkStatuses(), true) && !$feed->hasEditAccess($this->getUserId())) { return $this->sendError([ 'message' => __('Sorry, you do not have permission to view this post', 'fluent-community') ], 404); } /* * The row's own setting is the default the filter gets handed, rather than a bare * true. Before this, meta.enable_comments was read nowhere on this path, so a page * with comments switched off still served its thread to anyone who asked for it. */ $canViewComments = apply_filters( 'fluent_community/can_view_comments_' . $feed->type, FeedsHelper::commentsEnabled($feed), $feed ); if (!$canViewComments) { return [ 'comments' => [] ]; } $comments = Comment::where('post_id', $feed->id) ->byContentModerationAccessStatus($this->getUser()) ->orderBy('created_at', 'asc') ->with([ 'xprofile' => function ($q) { $q->select(ProfileHelper::getXProfilePublicFields()); } ]) ->whereHas('xprofile', function ($q) { $q->where('status', 'active'); }) ->get(); $comments = apply_filters('fluent_community/comments_query_response', $comments, $request->all()); $userId = $this->getUserId(); if ($userId) { $likedIds = FeedsHelper::getLikedIdsByUserFeedId($feed->id, get_current_user_id()); if ($likedIds) { $comments->each(function ($comment) use ($likedIds) { if (in_array($comment->id, $likedIds)) { $comment->liked = 1; } }); } } $data = [ 'comments' => $comments ]; return apply_filters('fluent_community/comments_api_response', $data, $request->all()); } public function store(Request $request, $feedId) { $user = $this->getUser(true); do_action('fluent_community/check_rate_limit/create_comment', $user); $text = $this->validateCommentText($request->all()); $feed = Feed::withoutGlobalScopes()->findOrFail($feedId); if (!in_array($feed->status, FeedsHelper::getViewableByLinkStatuses(), true)) { return $this->sendError([ 'message' => __('This post is not published yet', 'fluent-community') ]); } $this->verifyCreateCommentPermission($feed); $requestData = $request->all(); [$markdown, $inlineMedias] = FeedsHelper::replaceImageUrlsWithRealMediaArchive($text); $mentions = FeedsHelper::getMentions($markdown, $feed->space_id, true); $commentHtml = $this->generateCommentHtml($markdown, $mentions); $commentData = $this->prepareCommentData($feed->id, $text, $commentHtml); if (!empty($requestData['parent_id'])) { $parentId = (int)$requestData['parent_id']; $parentComment = Comment::where('id', $parentId) ->where('post_id', $feed->id) ->first(); if (!$parentComment) { return $this->sendError([ 'message' => __('Invalid parent comment', 'fluent-community') ]); } $commentData['parent_id'] = $parentId; } [$commentData, $mediaItems] = $this->prepareCommentMedia($commentData, $requestData); $commentData['is_admin'] = $user->hasSpacePermission('community_moderator', $feed->space); if ($mentionUserIds = Arr::get($mentions, 'user_ids', [])) { $commentData['meta']['mentioned_user_ids'] = $mentionUserIds; } do_action('fluent_community/before_comment_create', $commentData, $feed); $commentData = apply_filters('fluent_community/comment/comment_data', $commentData, $feed); // Only comments with text are duplicate checked $shouldCheckDuplicate = $text && !apply_filters('fluent_community/disable_duplicate_comment_check', false, get_current_user_id(), $feed->id); // Serialize a member's concurrent submissions by locking their profile row, // so parallel matching requests cannot pass the duplicate check and both insert. $comment = Helper::dbTransaction(function () use ($commentData, $feed, $text, $shouldCheckDuplicate) { XProfile::where('user_id', get_current_user_id())->lockForUpdate()->first(); if ($shouldCheckDuplicate && Comment::where('user_id', get_current_user_id())->where('message', $text)->where('post_id', $feed->id)->first()) { return null; } $newComment = Comment::create($commentData); Feed::withoutGlobalScopes()->where('id', $feed->id)->increment('comments_count'); return $newComment; }); if (!$comment) { return $this->sendError([ 'message' => __('No duplicate comment please!', 'fluent-community') ]); } $feed->comments_count = $feed->comments_count + 1; // Merge and save all media in one loop $mediaItems = $mediaItems ? (is_array($mediaItems) ? $mediaItems : [$mediaItems]) : []; if ($inlineMedias) { $mediaItems = array_merge($mediaItems, $inlineMedias); } if ($mediaItems) { foreach ($mediaItems as $media) { $media->fill([ 'is_active' => 1, 'feed_id' => $feed->id, 'object_source' => 'comment', 'sub_object_id' => $comment->id ]); $media->save(); } } $this->loadCommentRelations($comment); if ($comment->status != 'published') { do_action('fluent_community/comment/new_comment_' . $comment->status, $comment, $feed); /* translators: %$s is replaced by the status of the comment */ $message = sprintf(__('Your comment has been marked as %s', 'fluent-community'), $comment->status); $response = [ 'comment' => $comment, 'message' => $message ]; return apply_filters('fluent_community/comment/new_comment_response', $response, $comment); } do_action('fluent_community/comment_added_' . $feed->type, $comment, $feed); do_action('fluent_community/comment_added', $comment, $feed, Arr::get($mentions, 'users', [])); return [ 'comment' => $comment, 'message' => __('Comment has been added', 'fluent-community'), ]; } public function update(Request $request, $feedId, $commentId) { $text = $this->validateCommentText($request->all()); $feed = Feed::withoutGlobalScopes()->findOrFail($feedId); $this->verifySpacePermission($feed); $requestData = $request->all(); $comment = Comment::findOrFail($commentId); if ($comment->post_id != $feed->id) { return $this->sendError([ 'message' => __('Invalid comment', 'fluent-community') ]); } $user = $this->getUser(true); $requestData['is_admin'] = $user->hasPermissionOrInCurrentSpace('community_moderator', $feed->space); if ($comment->user_id != get_current_user_id() && !$user->can('edit_any_comment', $feed->space)) { return $this->sendError([ 'message' => __('You are not allowed to edit this comment', 'fluent-community') ]); } [$markdown, $inlineMedias] = FeedsHelper::replaceImageUrlsWithRealMediaArchive($text, $feed); $mentions = FeedsHelper::getMentions($markdown, $feed->space_id); $commentHtml = $this->generateCommentHtml($markdown, $mentions); $commentData = $this->prepareCommentData($feed->id, $text, $commentHtml); [$commentData, $mediaItems] = $this->prepareCommentMedia($commentData, $requestData, $comment); $commentData = apply_filters('fluent_community/comment/update_comment_data', $commentData, $feed, $requestData, $comment); $comment->fill($commentData); $dirty = $comment->getDirty(); if ($dirty) { $comment->save(); } // Merge and save all media in one loop $mediaItems = $mediaItems ? (is_array($mediaItems) ? $mediaItems : [$mediaItems]) : []; if ($inlineMedias) { $mediaItems = array_merge($mediaItems, $inlineMedias); } $allMediaIds = []; if ($mediaItems) { foreach ($mediaItems as $media) { $media->fill([ 'is_active' => 1, 'feed_id' => $feed->id, 'object_source' => 'comment', 'sub_object_id' => $comment->id ]); $media->save(); $allMediaIds[] = $media->id; } } // Remove old media not in current list $otherMedias = Media::where('object_source', 'comment') ->when($allMediaIds, function ($q) use ($allMediaIds) { $q->whereNotIn('id', $allMediaIds); }) ->where('sub_object_id', $comment->id) ->get(); if (!$otherMedias->isEmpty()) { do_action('fluent_community/comment/media_deleted', $otherMedias); } $this->loadCommentRelations($comment); if ($dirty) { do_action('fluent_community/comment_updated', $comment, $feed); do_action('fluent_community/comment_updated_' . $feed->type, $comment, $feed); } return [ 'comment' => $comment, 'message' => __('Comment has been updated', 'fluent-community'), ]; } public function patchComment(Request $request, $feedId, $commentId) { $feed = Feed::withoutGlobalScopes()->findOrFail($feedId); $comment = Comment::findOrFail($commentId); if ($comment->post_id != $feed->id) { return $this->sendError([ 'message' => __('Invalid comment', 'fluent-community') ]); } $user = $this->getUser(true); $isMod = $user && $user->hasPermissionOrInCurrentSpace('community_moderator', $feed->space); $isAdmin = $user && $user->hasPermissionOrInCurrentSpace('community_admin', $feed->space); if (!$isMod && !$isAdmin) { return $this->sendError([ 'message' => __('You do not have permission to perform this action', 'fluent-community') ]); } $allData = $request->all(); $validKeys = ['is_sticky']; $data = Arr::only($allData, $validKeys); $data = array_map('intval', $data); if (isset($data['is_sticky'])) { if ($comment->parent_id) { return $this->sendError([ 'message' => __('You cannot pin a reply comment', 'fluent-community') ]); } $data['is_sticky'] = $data['is_sticky'] ? 1 : 0; if ($data['is_sticky']) { Comment::where('post_id', $feed->id)->update(['is_sticky' => 0]); } } if ($data) { $comment->fill($data); $dirty = $comment->getDirty(); if ($dirty) { $comment->save(); do_action('fluent_community/comment/updated', $comment, $dirty); } } return apply_filters('fluent_community/comment/patch_comment_response', [ 'comment' => $comment, 'message' => __('Comment updated', 'fluent-community') ], $comment, $feed, $request->all()); } private function prepareCommentMedia($commentData, $requestData, $exisitngComment = null) { $mediaImages = Arr::get($requestData, 'media_images', []); if ($mediaImages) { if ($exisitngComment) { $mediaItems = []; $mediaData = []; foreach ($mediaImages as $mediaImage) { $id = Arr::get($mediaImage, 'media_id'); if ($id) { $media = Media::where('sub_object_id', $exisitngComment->id) ->where('object_source', 'comment') ->find($id); } else { $media = Helper::getMediaFromUrl($mediaImage); } if ($media) { $mediaItems[] = $media; $mediaData[] = [ 'media_id' => $media->id, 'url' => $media->public_url, 'type' => 'image', 'width' => Arr::get($media->settings, 'width'), 'height' => Arr::get($media->settings, 'height'), 'provider' => Arr::get($media->settings, 'provider', 'uploader') ]; } } $commentData['meta']['media_items'] = $mediaData; return [$commentData, $mediaItems]; } $uploadedImages = Helper::getMediaByProvider($mediaImages); if ($uploadedImages) { $mediaItems = Helper::getMediaItemsFromUrl($uploadedImages); if ($mediaItems) { $mediaPreviews = []; foreach ($mediaItems as $mediaItem) { $mediaData = [ 'media_id' => $mediaItem->id, 'url' => $mediaItem->public_url, 'type' => 'image', 'width' => Arr::get($mediaItem->settings, 'width'), 'height' => Arr::get($mediaItem->settings, 'height'), 'provider' => Arr::get($mediaItem->settings, 'provider', 'uploader') ]; $mediaPreviews[] = array_filter($mediaData); } $commentData['meta']['media_items'] = $mediaPreviews; return [$commentData, $mediaItems]; } } } if (empty($requestData['meta']['media_preview']['image'])) { return [$commentData, []]; } if ($exisitngComment) { $image = sanitize_url(Arr::get($requestData, 'meta.media_preview.image', '')); $existingMedia = Media::where('media_url', $image) ->where('object_source', 'comment') ->where('sub_object_id', $exisitngComment->id) ->first(); if ($existingMedia) { $commentData['meta'] = $exisitngComment->meta; return [$commentData, [$existingMedia]]; } } // type/provider reach :class bindings and width/height a :style binding in // _MediaPreview.vue. Neither is an executable sink, but the stored values are // request-supplied so they are normalised here rather than trusted. $commentData['meta']['media_preview'] = array_filter([ 'image' => sanitize_url(Arr::get($requestData, 'meta.media_preview.image', '')), 'type' => sanitize_text_field(Arr::get($requestData, 'meta.media_preview.type', 'image')), 'provider' => sanitize_text_field(Arr::get($requestData, 'meta.media_preview.provider', '')), 'height' => (int) Arr::get($requestData, 'meta.media_preview.height', 0), 'width' => (int) Arr::get($requestData, 'meta.media_preview.width', 0), ]); return [$commentData, []]; } private function validateCommentText($data) { $text = trim((string) Arr::get($data, 'comment', '')); $text = CustomSanitizer::unslashMarkdown($text); // Decode HTML entities (e.g., for space) and strip all whitespace for validation $textForValidation = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $textForValidation = preg_replace('/\s+/u', '', $textForValidation); $hasMedia = Arr::get($data, 'media_images', []) || Arr::get($data, 'meta.media_preview.image', false); $isReply = !empty($data['parent_id']); if (!$textForValidation && !$hasMedia) { if ($isReply) { throw new \Exception(esc_html__('Reply cannot be empty.', 'fluent-community'), 422); } else { throw new \Exception(esc_html__('Comment cannot be empty.', 'fluent-community'), 422); } } $maxCommentLength = apply_filters('fluent_community/max_comment_char_length', 10000); if ($text && strlen($text) > $maxCommentLength) { /* translators: %s is the maximum allowed character count */ throw new \Exception(esc_html(sprintf(__('The comment is too long. Please keep it under %s characters.', 'fluent-community'), number_format($maxCommentLength))), 422); } return $text; } private function verifyCreateCommentPermission($feed) { if (!FeedsHelper::commentsEnabled($feed)) { throw new \Exception(esc_html__('Comments are disabled for this post', 'fluent-community')); } $this->verifySpacePermission($feed); } private function verifySpacePermission($feed) { if ($feed->space_id && $feed->space) { $user = $this->getUser(true); $user->verifySpacePermission('can_comment', $feed->space); if ($feed->space->type == 'course' && Arr::get($feed->space->settings, 'disable_comments') === 'yes') { throw new \Exception(esc_html__('Comments are disabled for this course', 'fluent-community')); } } } private function generateCommentHtml($text, $mentions) { $htmlText = $mentions ? $mentions['text'] : $text; return wp_kses_post(FeedsHelper::mdToHtml($htmlText)); } private function prepareCommentData($feedId, $text, $commentHtml) { return [ 'post_id' => $feedId, 'message' => $text, 'message_rendered' => $commentHtml, 'type' => 'comment', 'meta' => [], ]; } private function loadCommentRelations($comment) { $comment->load('media'); $comment->load([ 'xprofile' => function ($q) { $q->select(ProfileHelper::getXProfilePublicFields()); } ]); } public function addOrRemovePostReact(Request $request, $feed_id) { $userId = get_current_user_id(); $feed = Feed::withoutGlobalScopes()->byUserAccess($userId)->findOrFail($feed_id); $type = $request->get('react_type', 'like'); $type = in_array($type, ['like', 'bookmark'], true) ? $type : 'like'; $willRemove = $request->get('remove'); if (!in_array($feed->status, FeedsHelper::getViewableByLinkStatuses(), true)) { return $this->sendError([ 'message' => __('This post is not published yet', 'fluent-community') ]); } if (!$willRemove && (int) $userId === (int) $feed->user_id && apply_filters('fluent_community/disable_self_post_react', false, $feed)) { return $this->sendError([ 'message' => __('You cannot react to your own post', 'fluent-community') ]); } $react = Reaction::where('user_id', $userId) ->where('object_id', $feed->id) ->where('type', $type) ->objectType('feed') ->first(); if ($willRemove) { if ($react) { $react->delete(); if ($type == 'like') { $feed->reactions_count = $feed->reactions_count - 1; $feed->timestamps = false; // Don't update the updated_at timestamp $feed->save(); do_action('fluent_community/feed/react_removed', $feed); } } return [ 'message' => __('Reaction has been removed', 'fluent-community'), 'new_count' => $feed->reactions_count ]; } if ($react) { return [ 'message' => __('You have already reacted to this post', 'fluent-community'), 'new_count' => $feed->reactions_count ]; } $react = Reaction::create([ 'user_id' => get_current_user_id(), 'object_id' => $feed->id, 'type' => $type, 'object_type' => 'feed' ]); if ($type == 'like') { $feed->reactions_count = $feed->reactions_count + 1; $feed->timestamps = false; // Don't update the updated_at timestamp $feed->save(); $react->load('xprofile'); do_action('fluent_community/feed/react_added', $react, $feed); } return [ 'message' => __('Reaction has been added', 'fluent-community'), 'new_count' => $feed->reactions_count ]; } public function deleteComment(Request $request, $feedId, $commentId) { $feed = Feed::withoutGlobalScopes()->findOrFail($feedId); $comment = Comment::findOrFail($commentId); if ($comment->post_id != $feed->id) { return $this->sendError([ 'message' => __('Invalid comment', 'fluent-community') ]); } $user = User::find(get_current_user_id()); if ($comment->user_id != get_current_user_id() && !$user->can('delete_any_comment', $feed->space)) { return $this->sendError([ 'message' => __('You are not allowed to delete this comment', 'fluent-community') ]); } do_action('fluent_community/before_comment_delete', $comment); if ($comment->media) { do_action('fluent_community/comment/media_deleted', $comment->media); } $comment->delete(); $feed->comments_count = Comment::where('post_id', $feed->id)->count(); $feed->timestamps = false; // Don't update the updated_at timestamp $feed->save(); do_action('fluent_community/comment_deleted_' . $feed->type, $commentId, $feed); do_action('fluent_community/comment_deleted', $commentId, $feed); return [ 'message' => __('Selected comment has been deleted', 'fluent-community') ]; } public function toggleReaction(Request $request, $feedId, $commentId) { $feed = Feed::withoutGlobalScopes()->byUserAccess(get_current_user_id())->findOrFail($feedId); $comment = Comment::findOrFail($commentId); if ($comment->post_id != $feed->id) { return $this->sendError([ 'message' => __('Invalid comment', 'fluent-community') ]); } $user = User::findOrFail(get_current_user_id()); if ($feed->space_id) { $user->verifySpacePermission('registered', $feed->space); } $userId = get_current_user_id(); $reactionState = !!$request->get('state', false); if ($reactionState && (int) $userId === (int) $comment->user_id && apply_filters('fluent_community/disable_self_comment_react', false, $feed)) { return $this->sendError([ 'message' => __('You cannot react to your own comment', 'fluent-community') ]); } if ($reactionState) { // Serialize concurrent reactions on this comment by locking its row, // so parallel add requests cannot each insert a duplicate reaction. $reaction = Helper::dbTransaction(function () use ($comment, $feed) { XProfile::where('user_id', get_current_user_id())->lockForUpdate()->first(); $reaction = Reaction::firstOrCreate([ 'user_id' => get_current_user_id(), 'object_id' => $comment->id, 'object_type' => 'comment', 'parent_id' => $feed->id ]); if ($reaction->wasRecentlyCreated) { Comment::where('id', $comment->id)->increment('reactions_count'); $comment->reactions_count = $comment->reactions_count + 1; } return $reaction; }); if ($reaction->wasRecentlyCreated) { do_action('fluent_community/comment/react_added', $reaction, $comment, $feed); } } else { // remove the reaction $deleted = Reaction::where('user_id', get_current_user_id()) ->where('object_id', $comment->id) ->where('object_type', 'comment') ->delete(); if ($deleted) { $comment->reactions_count = $comment->reactions_count - 1; $comment->save(); do_action('fluent_community/comment/react_removed', $comment, $feed); } } return [ 'message' => __('Reaction has been toggled', 'fluent-community'), 'reactions_count' => $comment->reactions_count, 'liked' => $reactionState ]; } public function show(Request $request, $id) { $testComment = Comment::query()->findOrFail($id); $comment = Comment::byContentModerationAccessStatus($this->getUser(), $testComment->space) ->with([ 'xprofile' => function ($q) { return $q->select(ProfileHelper::getXProfilePublicFields()); } ])->findOrFail($id); // Just to verify the permission Feed::withoutGlobalScopes() ->byUserAccess($this->getUserId()) ->findOrFail($comment->post_id); if ($request->get('context') == 'edit') { $meta = $comment->meta; unset($comment->meta); $images = Arr::get($meta, 'media_items', []); if ($images) { $comment->media_images = $images; } else { $preview = Arr::get($meta, 'media_preview', []); if ($preview) { $previewUrl = Arr::get($preview, 'image'); $provider = Arr::get($preview, 'provider'); if ($previewUrl && $provider == 'uploader') { $media = Media::where('media_url', $previewUrl) ->where('object_source', 'comment') ->where('sub_object_id', $comment->id) ->first(); if ($media) { $comment->media_images = [ [ 'media_id' => $media->id, 'url' => $media->public_url, 'type' => $media->media_type, 'width' => Arr::get($media->settings, 'width'), 'height' => Arr::get($media->settings, 'height'), 'provider' => Arr::get($media->settings, 'provider', 'uploader') ] ]; } } else { $comment->meta = [ 'media_preview' => $preview ]; } } } } $data = [ 'comment' => $comment ]; return apply_filters('fluent_community/comment_api_response', $data, $request->all()); } }