get('space'); $userId = $request->getSafe('user_id', 'intval', ''); $selectedTopic = $request->getSafe('topic_slug', 'sanitize_text_field', ''); $search = $request->getSafe('search', 'sanitize_text_field', ''); if ($bySpace) { // just for validation $space = BaseSpace::where('slug', $bySpace)->first(); if (!$space) { return $this->sendError(['message' => __('Invalid space slug', 'fluent-community')]); } } $currentUserModel = $this->getUser(); $currentUserId = get_current_user_id(); $isOwnProfile = $userId && (int)$userId === (int)$currentUserId; $filterableStatuses = apply_filters('fluent_community/feed/filterable_statuses', []); $statusFilter = $request->getSafe('status', 'sanitize_text_field', ''); $applyStatusFilter = $statusFilter && in_array($statusFilter, $filterableStatuses, true) && (Helper::isModerator() || $isOwnProfile); $maxPerPage = (int) apply_filters('fluent_community/max_per_page', 100) ?: 100; $queryArgs = [ 'selected_topic' => $selectedTopic, 'per_page' => min($maxPerPage, max(1, (int)$request->get('per_page', 10))), 'page' => max(1, (int)$request->get('page', 1)), 'search' => $search, ]; $feedsQuery = Feed::select(Feed::$publicColumns) ->with(Feed::withPublicRelations($currentUserModel, $space)) ->searchBy($search, (array)$request->get('search_in', ['post_content'])) ->byTopicSlug($selectedTopic) ->customOrderBy($request->getSafe('order_by_type')); if ($applyStatusFilter) { $feedsQuery->byStatus($statusFilter); } else { $feedsQuery->byContentModerationAccessStatus($currentUserModel, $space); } $stickyFeed = null; $disableSticky = $request->get('disable_sticky', '') == 'yes' || !!$search || !!$selectedTopic; if ($bySpace) { $feedsQuery = $feedsQuery->filterBySpaceSlug($bySpace); $queryArgs['space_slug'] = $bySpace; } if ($bySpace && !$disableSticky) { $feedsQuery = $feedsQuery->where('is_sticky', 0); if ($queryArgs['page'] === 1) { $stickyFeed = Feed::where('space_id', $space->id) ->where('is_sticky', 1) ->byUserAccess($currentUserId) ->byContentModerationAccessStatus($currentUserModel, $space) ->with(Feed::withPublicRelations($this->getUser(), $space)) ->first(); } } if ($userId) { $feedsQuery = $feedsQuery->where('user_id', $userId); if (!Helper::isModerator()) { $feedsQuery = $feedsQuery->whereHas('xprofile', function ($q) { $q->where('status', 'active'); }); } if ($userId != $currentUserId) { $feedsQuery = $feedsQuery->byUserAccess($currentUserId); } $queryArgs['user_id'] = $userId; } else { $feedsQuery->byUserAccess($currentUserId)->whereHas('xprofile', function ($q) { $q->where('status', 'active'); }); } $queryArgs = array_filter($queryArgs); $queryArgs['is_main_query'] = empty($queryArgs['space_slug']) && empty($queryArgs['user_id']) && empty($queryArgs['search']); do_action_ref_array('fluent_community/feeds_query', [&$feedsQuery, $request->all(), $queryArgs]); $feedsQuery->limit($queryArgs['per_page'])->offset(($queryArgs['page'] - 1) * $queryArgs['per_page']); $feeds = $feedsQuery->get(); // add $stickyFeed to the first page if ($stickyFeed) { $stickyFeed = FeedsHelper::transformFeed($stickyFeed); } $feeds = FeedsHelper::transformFeedsCollection($feeds); $currentCount = $feeds->count(); $to = ($queryArgs['page'] - 1) * $queryArgs['per_page'] + $currentCount; $hasMore = $currentCount == $queryArgs['per_page']; $data = [ 'feeds' => [ 'data' => $feeds, 'current_page' => $queryArgs['page'], 'per_page' => $queryArgs['per_page'], 'from' => $currentCount ? ($queryArgs['page'] - 1) * $queryArgs['per_page'] + 1 : 0, 'to' => $to, 'has_more' => $hasMore, 'total' => $currentCount == $queryArgs['per_page'] ? $to + $currentCount : $to ], 'sticky' => $stickyFeed ]; $isMainFeed = $queryArgs['page'] === 1 && !$search && !$userId; if ($isMainFeed && $currentUserId) { $data['last_fetched_timestamp'] = current_time('timestamp'); } $data['execution_time'] = microtime(true) - $start; $data = apply_filters('fluent_community/feeds_api_response', $data, $request->all()); return $data; } public function getFeedBySlug(Request $request, $feed_slug) { $start = microtime(true); if ($request->get('context') == 'edit') { $feed = Feed::where('slug', $feed_slug)->first(); if (!$feed || !$feed->hasEditAccess(get_current_user_id())) { return $this->sendError([ 'message' => __('You do not have permission to edit this feed', 'fluent-community') ]); } $data = [ 'feed' => FeedsHelper::transformForEdit($feed) ]; return apply_filters('fluent_community/feed_api_response', $data, $request->all()); } $feed = Feed::where('slug', $feed_slug) ->select(Feed::$publicColumns) ->with(Feed::withPublicRelations($this->getUser())) ->whereHas('xprofile', function ($q) { $q->where('status', 'active'); }) ->byUserAccess($this->getUserId()) ->first(); if (!$feed) { return $this->sendError([ 'message' => __('The feed could not be found', 'fluent-community') ], 404); } $viewableByLinkStatuses = FeedsHelper::getViewableByLinkStatuses(); if (!in_array($feed->status, $viewableByLinkStatuses, true) && !$feed->hasEditAccess($this->getUserId())) { return $this->sendError([ 'message' => __('Sorry, you do not have permission to view this post', 'fluent-community') ], 404); } $feed = FeedsHelper::transformFeed($feed); return apply_filters('fluent_community/feed_api_response', [ 'feed' => $feed, 'execution_time' => microtime(true) - $start ], $request->all()); } public function getFeedById(Request $request, $feedId) { $feed = Feed::findOrFail($feedId); return $this->getFeedBySlug($request, $feed->slug); } public function getBookmarks(Request $request) { $userId = $this->getUserId(); $feedsQuery = Feed::where('status', 'published') ->select(Feed::$publicColumns) ->with(Feed::withPublicRelations($this->getUser())) ->byBookMarked($userId) ->byUserAccess($userId) ->byTopicSlug($request->getSafe('topic_slug')) ->customOrderBy($request->getSafe('order_by_type')) ->searchBy($request->getSafe('search')); if ($type = $request->get('type')) { $feedsQuery = $feedsQuery->where('type', $type); } $queryArgs = [ 'per_page' => (int)$request->get('per_page', 10), 'page' => (int)$request->get('page', 1) ]; $feeds = $feedsQuery->orderBy('id', 'DESC') ->limit($queryArgs['per_page']) ->offset(($queryArgs['page'] - 1) * $queryArgs['per_page']) ->get(); $currentCount = $feeds->count(); $to = ($queryArgs['page'] - 1) * $queryArgs['per_page'] + $currentCount; $hasMore = $currentCount == $queryArgs['per_page']; $feeds = FeedsHelper::transformFeedsCollection($feeds); $data = [ 'feeds' => [ 'data' => $feeds, 'current_page' => $queryArgs['page'], 'per_page' => $queryArgs['per_page'], 'from' => $currentCount ? ($queryArgs['page'] - 1) * $queryArgs['per_page'] + 1 : 0, 'to' => $to, 'has_more' => $hasMore, 'total' => $currentCount == $queryArgs['per_page'] ? $to + $currentCount : $to ] ]; if ($queryArgs['page'] === 1) { $lastItem = FeedsHelper::getLastFeedId(); if ($lastItem) { $data['last_id'] = $lastItem; } } return apply_filters('fluent_community/bookmarks_api_response', $data, $request->all()); } public function store(Request $request) { $user = $this->getUser(true); do_action('fluent_community/check_rate_limit/create_post', $user); $requestData = $request->all(); $data = $this->sanitizeAndValidateData($requestData); $data['user_id'] = $user->ID; $data['status'] = 'published'; $data['status'] = apply_filters('fluent_community/feed/save_status', $data['status'], $requestData, null); $feed = new Feed(); $feed->user_id = $user->ID; $space = null; if ($spaceSlug = $request->get('space')) { $data['space_id'] = $this->validateAndSetSpace($spaceSlug, $user); if ($data['space_id']) { $space = Space::where('id', $data['space_id'])->first(); if (!$space) { return $this->sendError([ 'message' => __('Please select a valid space to post in.', 'fluent-community') ]); } } if ($space && Arr::get($space->settings, 'topic_required') == 'yes') { $topicIds = (array)$request->get('topic_ids', []); $spaceTopics = Utility::getTopicsBySpaceId($space->id); $spaceTopicsIds = []; foreach ($spaceTopics as $topic) { $spaceTopicsIds[] = $topic['id']; } $validTopicIds = array_intersect($topicIds, $spaceTopicsIds); if (!$validTopicIds) { return $this->sendError([ 'message' => __('Please select at least one topic to post in this space.', 'fluent-community'), 'shakes' => [ 'topic_ids' => true ] ]); } } } else if (!Helper::hasGlobalPost()) { return $this->sendError([ 'message' => __('Please select a valid space to post in.', 'fluent-community') ]); } $spaceId = Arr::get($data, 'space_id'); $message = Arr::get($data, 'message'); $duplicateCheckMessage = $message; $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'), true); if ($mentions) { $data['message'] = $message; $message = $mentions['text']; } [$message, $inlineMedias] = FeedsHelper::replaceImageUrlsWithRealMediaArchive($message); // replace new line with br $data['message_rendered'] = wp_kses_post(FeedsHelper::mdToHtml($message)); $requestData['is_admin'] = $user->hasPermissionOrInCurrentSpace('community_moderator', $space); [$data, $mediaItems] = FeedsHelper::processFeedMetaData($data, $requestData); if ($inlineMedias) { $mediaItems = array_merge($mediaItems, $inlineMedias); } if (Arr::get($requestData, 'send_announcement_email') == 'yes' && $requestData['is_admin']) { $data['meta']['send_announcement_email'] = 'yes'; } else if (isset($data['meta']['send_announcement_email'])) { $data['meta']['send_announcement_email'] = 'no'; } if ($mentions) { $data['meta']['mentioned_user_ids'] = Arr::get($mentions, 'user_ids', []); } $data = apply_filters('fluent_community/feed/new_feed_data', $data, $requestData); $formContentType = (string)Arr::get($requestData, 'content_type', ''); if ($formContentType) { $data = apply_filters('fluent_community/feed/new_feed_data_type_' . $formContentType, $data, $requestData); } if (is_wp_error($data)) { return $this->sendError([ 'message' => $data->get_error_message(), 'errors' => $data->get_error_data() ]); } $feed->fill($data); // Serialize a member's concurrent submissions by locking their profile row, // so parallel matching requests cannot pass the duplicate check and both insert. $isDuplicate = Helper::dbTransaction(function () use ($feed, $user, $spaceId, $duplicateCheckMessage) { XProfile::where('user_id', $user->ID)->lockForUpdate()->first(); if ($duplicate = $this->checkForDuplicatePost($user->ID, $duplicateCheckMessage, $spaceId)) { return $duplicate; } $feed->save(); return null; }); if ($isDuplicate) { return $isDuplicate; } $feed = Feed::find($feed->id); // just renewing the feed if ($mentions) { do_action('fluent_community/feed_mentioned', $feed, Arr::get($mentions, 'users')); } if ($formContentType) { do_action('fluent_community/feed/just_created_type_' . $formContentType, $feed, $requestData); } if ($mediaItems) { $this->saveMediaItems($feed, $mediaItems); } $feed->load(['xprofile', 'comments.xprofile']); if ($feed->space_id) { $feed->load(['space']); $topicIds = (array)$request->get('topic_ids', []); // take only max topics per post if ($topicIds) { $topicsConfig = Helper::getTopicsConfig(); $topicIds = array_slice($topicIds, 0, $topicsConfig['max_topics_per_post']); $feed->attachTopics($topicIds, false); $feed->load(['terms']); } } if ($feed->status == 'scheduled') { do_action('fluent_community/feed/scheduled', $feed); /* translators: %s: The scheduled date and time for the post */ $message = sprintf(__('Your post has been scheduled for %s', 'fluent-community'), $feed->scheduled_at); return [ 'feed' => FeedsHelper::transformFeed($feed), 'scheduled_at' => $feed->scheduled_at, 'message' => $message, 'last_fetched_timestamp' => current_time('timestamp') ]; } if (!in_array($feed->status, ['published', 'unlisted'])) { do_action('fluent_community/feed/new_feed_' . $feed->status, $feed); /* translators: %s: The status of the post */ $message = sprintf(__('Your post has been marked as %s', 'fluent-community'), $feed->status); return apply_filters('fluent_community/feed/new_feed_response', [ 'feed' => FeedsHelper::transformFeed($feed), 'message' => $message, 'last_fetched_timestamp' => current_time('timestamp') ], $feed, $request->all()); } do_action('fluent_community/feed/created', $feed); if ($feed->space_id) { do_action('fluent_community/space_feed/created', $feed); } else { do_action('fluent_community/profile_feed/created', $feed); } $message = __('Your post has been published', 'fluent-community'); return apply_filters('fluent_community/feed/new_feed_response', [ 'feed' => FeedsHelper::transformFeed($feed), 'message' => $message, 'last_fetched_timestamp' => current_time('timestamp') ], $feed, $request->all()); } public function update(Request $request, $feedId) { $requestData = $request->all(); $data = $this->sanitizeAndValidateData($requestData); $user = $this->getUser(true); $existingFeed = Feed::findOrFail($feedId); /** @var Feed $existingFeed */ $editableStatuses = ['published', 'unlisted', 'scheduled', 'pending']; if (!in_array($existingFeed->status, $editableStatuses)) { return $this->sendError([ 'message' => __('Sorry, this post is not in an editable state.', 'fluent-community') ]); } $user->canEditFeed($existingFeed, true); // Must resolve before processFeedMetaData() reads it. $isModerator = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space); $requestData['is_admin'] = $isModerator; if ($surveyOptionError = FeedsHelper::getSurveyOptionsUpdateError( Arr::get($existingFeed->meta, 'survey_config.options', []), Arr::get($requestData, 'survey', []) )) { return $this->sendError([ 'message' => $surveyOptionError ]); } if ($isModerator && ($status = Arr::get($requestData, 'status'))) { if (in_array($status, $editableStatuses, true)) { $fallbackStatus = $status === 'unlisted' ? $existingFeed->status : $status; $data['status'] = apply_filters('fluent_community/feed/save_status', $fallbackStatus, $requestData, $existingFeed); } } $message = $data['message']; $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id')); if ($mentions) { $data['message'] = $message; $message = $mentions['text']; } [$message, $inlineMedias] = FeedsHelper::replaceImageUrlsWithRealMediaArchive($message, $existingFeed); // replace new line with br $data['message_rendered'] = wp_kses_post(FeedsHelper::mdToHtml($message)); [$data, $mediaItems] = FeedsHelper::processFeedMetaData($data, $requestData, $existingFeed); if($inlineMedias) { $mediaItems = array_merge($mediaItems, $inlineMedias); } if (isset($existingFeed->meta['comments_disabled'])) { $data['meta']['comments_disabled'] = $existingFeed->meta['comments_disabled']; } if (Arr::get($requestData, 'send_announcement_email') == 'yes' && $requestData['is_admin']) { $data['meta']['send_announcement_email'] = 'yes'; } else if (Arr::get($existingFeed->meta, 'send_announcement_email')) { $data['meta']['send_announcement_email'] = Arr::get($existingFeed->meta, 'send_announcement_email'); } $data = apply_filters('fluent_community/feed/update_feed_data', $data, $requestData); if (is_wp_error($data)) { return $this->sendError([ 'message' => $data->get_error_message(), 'errors' => $data->get_error_data() ]); } $newContentType = Arr::get($requestData, 'content_type', ''); $existingContentType = $existingFeed->content_type; if (($newContentType === 'document' && empty($requestData['document_ids'])) || ($newContentType === '' && $existingContentType === 'document' && empty($requestData['survey']))) { $newContentType = $data['content_type'] = 'text'; } if ($newContentType != $existingContentType) { // Content Type Changed do_action('fluent_community/feed/updating_content_type_old_' . $existingContentType, $existingFeed, $newContentType, $requestData); } if ($newContentType != 'text') { $data = apply_filters('fluent_community/feed/update_feed_data_type_' . $newContentType, $data, $requestData, $existingFeed); if (is_wp_error($data)) { return $this->sendError([ 'message' => $data->get_error_message(), 'errors' => $data->get_error_data() ]); } } if ($message != $existingFeed->message) { $data['meta']['last_edited'] = [ 'user_id' => $user->ID, 'time' => current_time('mysql') ]; } $movingToProfile = false; if ($newSpaceId = $request->get('new_space_id')) { if (!Helper::isUserInSpace($existingFeed->user_id, $newSpaceId)) { return $this->sendError([ 'message' => __('The author is not a member of the selected space', 'fluent-community') ]); } $newSpace = Space::findOrFail($newSpaceId); // check if the current user is admin if (!$user->hasPermissionOrInCurrentSpace('community_admin', $newSpace)) { return $this->sendError([ 'message' => __('Sorry, you do not have permission to change the space for this post', 'fluent-community') ]); } $data['space_id'] = $newSpaceId; \FluentCommunity\App\Models\Activity::where('feed_id', $existingFeed->id) ->update(['space_id' => $newSpaceId]); } else if ($request->get('move_to_profile')) { if (!$user->hasPermissionOrInCurrentSpace('community_admin', $existingFeed->space)) { return $this->sendError([ 'message' => __('Sorry, you do not have permission to move this post to a profile', 'fluent-community') ]); } $data['space_id'] = null; $movingToProfile = true; \FluentCommunity\App\Models\Activity::where('feed_id', $existingFeed->id) ->update(['space_id' => null]); } $data = apply_filters('fluent_community/feed/update_data', $data, $existingFeed); $existingFeed->fill($data); $dirty = $existingFeed->getDirty(); $existingFeed->fill($data); $existingFeed->save(); if ($message != $existingFeed->message) { $editHistory = $existingFeed->getCustomMeta('_edit_history', []); if (!$editHistory) { $editHistory = []; } $editHistory[] = array_filter([ 'user_id' => $user->ID, 'time' => current_time('mysql'), 'prev_message' => $existingFeed->message, 'prev_title' => $existingFeed->title ]); // get last 5 edit history $editHistory = array_slice($editHistory, -5); $existingFeed->updateCustomMeta('_edit_history', $editHistory); } $mediaItemIds = []; foreach ($mediaItems as $mediaItem) { $mediaItemIds[] = $mediaItem->id; } if (Arr::has($requestData, 'media_images')) { $deactivateQuery = Media::where('object_source', 'feed') ->where('feed_id', $existingFeed->id) ->whereNotIn('id', $mediaItemIds); if (empty(Arr::get($requestData, 'media_images'))) { $deactivateQuery->where('media_type', '!=', 'fluent_player'); } $deactivateQuery->update(['is_active' => 0]); } if ($mediaItems) { $this->saveMediaItems($existingFeed, $mediaItems); } $existingFeed->load(['xprofile', 'comments.xprofile']); if ($existingFeed->space_id) { $existingFeed->load(['space']); $space = $existingFeed->space; $topicIds = (array)Arr::get($requestData, 'topic_ids', []); $topicsConfig = Helper::getTopicsConfig(); // take only max topics per post if ($topicIds) { $topicIds = array_slice($topicIds, 0, $topicsConfig['max_topics_per_post']); $existingFeed->attachTopics($topicIds, true); } else { if ($space && Arr::get($space->settings, 'topic_required') != 'yes') { $existingFeed->terms()->where('taxonomy_name', 'post_topic')->detach(); } } } else if ($movingToProfile) { // Topics are space-scoped; a post moved to the profile must not keep them. $existingFeed->terms()->where('taxonomy_name', 'post_topic')->detach(); } if ($dirty) { do_action('fluent_community/feed/updated', $existingFeed, $dirty); if ($existingFeed->space_id) { do_action('fluent_community/space_feed/updated', $existingFeed); } } $data = [ 'feed' => FeedsHelper::transformFeed($existingFeed), 'message' => __('Your post has been updated', 'fluent-community') ]; return apply_filters('fluent_community/feed/update_feed_response', $data, $request->all()); } public function patchFeed(Request $request, $feedId) { $feed = Feed::findOrFail($feedId); $user = $this->getUser(true); $isAuthor = $feed->user_id == $user->ID; $isMod = $user->hasPermissionOrInCurrentSpace('community_moderator', $feed->space); $isAdmin = $user->hasPermissionOrInCurrentSpace('community_admin', $feed->space); if (!$isMod && !$isAuthor && !$isAdmin) { return $this->sendError([ 'message' => __('You do not have permission to perform this action', 'fluent-community') ]); } $allData = $request->all(); $validKeys = ['is_sticky', 'priority', 'comments_disabled']; if (!$isMod) { $validKeys = ['comments_disabled']; } $data = Arr::only($allData, $validKeys); $data = array_map('intval', $data); // List/unlist toggle — community-moderator only, routed through the shared save_status filter. if (Helper::isModerator($user) && ($reqStatus = Arr::get($allData, 'status')) && in_array($reqStatus, ['published', 'unlisted'], true) && in_array($feed->status, ['published', 'unlisted'], true) ) { $fallbackStatus = $reqStatus === 'unlisted' ? $feed->status : $reqStatus; $data['status'] = apply_filters('fluent_community/feed/save_status', $fallbackStatus, $allData, $feed); } if (isset($data['is_sticky'])) { $data['is_sticky'] = $data['is_sticky'] ? 1 : 0; if ($data['is_sticky'] && $feed->space_id) { // toBase() keeps the type scope but skips the Orm update()'s updated_at stamp, which would bump the post being un-stuck. Feed::where('space_id', $feed->space_id) ->where('is_sticky', 1) ->toBase() ->update(['is_sticky' => 0]); } } if (isset($data['comments_disabled'])) { $meta = $feed->meta; $meta['comments_disabled'] = $data['comments_disabled'] ? 'yes' : 'no'; $data['meta'] = $meta; } if ($data) { $feed->fill($data); $dirty = $feed->getDirty(); if ($dirty) { // Only a real list/unlist transition is activity, so read $dirty, not the request. if (!array_key_exists('status', $dirty)) { $feed->timestamps = false; } $feed->save(); do_action('fluent_community/feed/updated', $feed, $dirty); } } return apply_filters('fluent_community/feed/patch_feed_response', [ 'feed' => $feed, 'message' => __('Feed updated', 'fluent-community') ], $feed, $request->all()); } public function getWelcomeBanner(Request $request) { $scope = get_current_user_id() ? 'login' : 'logout'; $data = [ 'welcome_banner' => Helper::getWelcomeBanner($scope) ]; return apply_filters('fluent_community/welcome_banner_api_response', $data, $request->all()); } public function getLinks(Request $request) { $scope = $request->getSafe('scope'); if ($scope == 'view') { $data = [ 'links' => Helper::getEnabledFeedLinks() ]; return apply_filters('fluent_community/feed_links_api_response', $data, $request->all()); } $data = [ 'links' => Helper::getFeedLinks() ]; return apply_filters('fluent_community/feed_links_api_response', $data, $request->all()); } public function updateLinks(Request $request) { $links = $request->get('links', []); $links = array_map(function ($link) { return CustomSanitizer::santizeLinkItem($link); }, $links); Helper::updateFeedLinks($links); return [ 'message' => __('Links have been updated.', 'fluent-community'), 'links' => $links ]; } private function saveMediaItems($feed, $mediaItems) { foreach ($mediaItems as $media) { $media->feed_id = $feed->id; $media->is_active = 1; $media->object_source = 'feed'; $media->save(); } } private function sanitizeAndValidateData($data) { $data['type'] = 'text'; $this->validate($data, [ 'message' => 'required' ], [ 'message.required' => __('Message is required', 'fluent-community'), ]); return FeedsHelper::sanitizeAndValidateData($data); } private function checkForDuplicatePost($userId, $message, $spaceId = null) { if (apply_filters('fluent_community/disable_duplicate_post_check', false, $userId, $spaceId)) { return false; } $message = trim($message); $exist = Feed::where('user_id', $userId) ->where('message', $message) ->where('created_at', '>', gmdate('Y-m-d H:i:s', current_time('timestamp') - 7 * 24 * 60 * 60)) ->when($spaceId, function ($query) use ($spaceId) { $query->where('space_id', $spaceId); }) ->first(); if ($exist) { return $this->sendError(['message' => __('No duplicate post please!', 'fluent-community')]); } return false; } private function validateAndSetSpace($spaceSlug, $user) { if ($spaceSlug == '__self__post__') { if (!Helper::hasGlobalPost()) { throw new \Exception(esc_html__('Please select a valid space to post in', 'fluent-community')); } return null; } $space = Space::where('slug', $spaceSlug)->first(); if (!$space) { throw new \Exception(esc_html__('Please select a valid space to post in', 'fluent-community')); } $user->verifySpacePermission('can_create_post', $space); return $space->id; } public function deleteFeed(Request $request, $feed_id) { $feed = Feed::findOrFail($feed_id); $user = User::find(get_current_user_id()); $user->canDeleteFeed($feed, true); do_action('fluent_community/feed/before_deleted', $feed); $feed->delete(); do_action('fluent_community/feed/deleted', $feed_id); return [ 'message' => __('Feed has been deleted successfully', 'fluent-community') ]; } public function deleteMediaPreview(Request $request, $feed_id) { $feed = Feed::findOrFail($feed_id); $user = User::find(get_current_user_id()); $user->canDeleteFeed($feed, true); //do_action('fluent_community/feed/media_deleted', $feed->media); $meta = $feed->meta; $meta['media_preview'] = null; $feed->meta = $meta; $feed->save(); return [ 'message' => __('Media preview image has been removed successfully.', 'fluent-community') ]; } public function handleMediaUpload(Request $request) { if ($error = Helper::checkUploadSizeError()) { return $this->sendError($error, 413); } $user = $this->getUser(true); do_action('fluent_community/check_rate_limit/media_upload', $user); $allowedMimeTypesArray = apply_filters('fluent_community/support_attachment_types', [ 'image/jpeg', 'image/pjpeg', 'image/png', 'image/gif', 'image/webp', 'image/heic', ]); $allowedTypes = implode(',', $allowedMimeTypesArray); // Extensions eligible for WebP conversion (from allowed MIME types, excluding webp) $convertibleExtensions = []; foreach ($allowedMimeTypesArray as $mime) { $element = explode('/', $mime); $ext = end($element); if ($ext === 'pjpeg') { $ext = 'jpeg'; } if ($ext && $ext !== 'webp' && !in_array($ext, $convertibleExtensions)) { $convertibleExtensions[] = $ext; } } // jpg is a common alias for jpeg — add only if jpeg is allowed if (in_array('jpeg', $convertibleExtensions)) { $convertibleExtensions[] = 'jpg'; } $maxFileUnit = apply_filters('fluent_community/media_upload_max_file_unit', 'MB'); $maxFileSize = apply_filters('fluent_community/media_upload_max_file_size', 100); $allowedFileSize = $maxFileSize; if (strtoupper($maxFileUnit) == 'MB') { $allowedFileSize = $maxFileSize * 1024; } else if (strtoupper($maxFileUnit) == 'GB') { $allowedFileSize = $maxFileSize * 1024 * 1024; } $files = $this->validate($this->request->files(), [ 'file' => 'required|mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize, ], [ 'file.required' => __('No upload file was received. Please try again.', 'fluent-community'), 'file.mimetypes' => __('The file must be an image type.', 'fluent-community'), /* translators: %$1s is replaced by the maximum allowed file size, %2$s is replaced by the file size unit (e.g. MB) */ 'file.max' => sprintf(__('The file size must be less than %1$s%2$s.', 'fluent-community'), $maxFileSize, $maxFileUnit) ]); if (Arr::get($files, 'file.type') === 'image/heic' && (!extension_loaded('imagick') || !class_exists('Imagick') || !in_array('HEIC', \Imagick::queryFormats('HEIC'))) ) { return $this->sendError([ 'message' => __('HEIC image format is not supported on this system.', 'fluent-community') ]); } add_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']); $uploadedFiles = FileSystem::put($files); remove_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']); $file = Arr::get($uploadedFiles, 0); if (is_wp_error($file)) { return $this->sendError([ 'message' => $file->get_error_message() ]); } // an empty request body reaches here with nothing uploaded; never build media data from it if (!is_array($file) || empty($file['url']) || empty($file['file']) || empty($file['type'])) { return $this->sendError([ 'message' => __('No upload file was received. Please try again.', 'fluent-community') ]); } $upload_dir = wp_upload_dir(); $originalUrl = $file['url']; $orginalPath = $upload_dir['basedir'] . '/fluent-community/' . $file['file']; $originalFileType = $file['type']; $originalFileName = $file['file']; $willWebPConvert = $request->get('disable_convert') != 'yes'; $willWebPConvert = apply_filters('fluent_community/convert_image_to_webp', $willWebPConvert, $file); $willResize = $request->get('resize'); $maxWidth = $request->get('max_width'); $willResize = apply_filters('fluent_community/media_upload_resize', $willResize, $file); if ($context = $request->get('context')) { $maxWidth = apply_filters('fluent_community/media_upload_max_width_' . $context, $maxWidth, $file); } if ($willResize && $maxWidth) { $upload_dir = wp_upload_dir(); $fileUrl = str_replace($upload_dir['baseurl'], $upload_dir['basedir'], $file['url']); $editor = wp_get_image_editor($fileUrl); if (!is_wp_error($editor) && $editor->get_size()['width'] > $maxWidth) { // Current file extension $ext = pathinfo($file['url'], PATHINFO_EXTENSION); $willConvert = in_array($ext, $convertibleExtensions) && $willWebPConvert; if ($willConvert) { $dottedExtensions = array_map(function ($ext) { return '.' . $ext; }, $convertibleExtensions); $fileUrl = str_replace($dottedExtensions, '.webp', $fileUrl); $file['file'] = str_replace($dottedExtensions, '.webp', $file['file']); $file['url'] = str_replace($dottedExtensions, '.webp', $file['url']); $file['type'] = 'image/webp'; } // resize the image $editor->resize($maxWidth, null, false); $editor->set_quality(90); if ($willConvert) { $result = $editor->save($fileUrl, 'image/webp'); if ($result['mime-type'] == 'image/webp') { // remove original file now wp_delete_file(str_replace('.webp', '.' . $ext, $fileUrl)); } $file['is_converted'] = true; } else { $result = $editor->save($fileUrl); } if ($result['mime-type'] != 'image/webp') { $file['file'] = $originalFileName; $file['url'] = $originalUrl; $file['type'] = $result['mime-type']; } $file['meta'] = [ 'width' => $editor->get_size()['width'], 'height' => $editor->get_size()['height'] ]; } $file['path'] = $upload_dir['basedir'] . '/fluent-community/' . $file['file']; } else { $upload_dir = wp_upload_dir(); $file['path'] = $upload_dir['basedir'] . '/fluent-community/' . $file['file']; } if ($willWebPConvert && empty($file['is_converted']) && !$request->get('skip_convert')) { $path = $file['path']; $extension = pathinfo($path, PATHINFO_EXTENSION); if ($extension != 'webp' && in_array($extension, $convertibleExtensions)) { // Let's convert to webp $editor = wp_get_image_editor($file['path']); if (!is_wp_error($editor)) { $file['path'] = str_replace('.' . $extension, '.webp', $file['path']); $file['url'] = str_replace('.' . $extension, '.webp', $file['url']); $file['type'] = 'image/webp'; $result = $editor->save($file['path'], 'image/webp'); if ($result['mime-type'] != 'image/webp') { $file['path'] = $orginalPath; $file['url'] = $originalUrl; $file['type'] = $result['mime-type']; } else { wp_delete_file($orginalPath); } $file['meta'] = [ 'width' => $editor->get_size()['width'], 'height' => $editor->get_size()['height'] ]; } } } $mediaData = [ 'media_type' => $file['type'], 'driver' => 'local', 'media_path' => $file['path'], 'media_url' => $file['url'], 'settings' => Arr::get($file, 'meta', []) ]; $mediaData = apply_filters('fluent_community/media_upload_data', $mediaData, $file); if (is_wp_error($mediaData)) { return $this->sendError([ 'message' => $mediaData->get_error_message(), 'errors' => $mediaData->get_error_data() ]); } if (!$mediaData) { return $this->sendError([ 'message' => __('Error while uploading the media', 'fluent-community') ]); } // Let's create the media now $media = Media::create($mediaData); $mediaUrl = $media->public_url; $mediaUrl = add_query_arg([ 'media_key' => $media->media_key, ], $mediaUrl); return [ 'media' => [ 'url' => $mediaUrl, 'media_key' => $media->media_key, 'type' => $media->media_type, 'width' => Arr::get($media->settings, 'width'), 'height' => Arr::get($media->settings, 'height') ] ]; } public function getTicker(Request $request) { $start = microtime(true); $userId = get_current_user_id(); if (!$userId) { return [ 'timestamp' => current_time('mysql', true), 'has_changes' => false, 'error' => __('User not authenticated', 'fluent-community'), 'feeds' => [] ]; } do_action('fluent_community/track_activity'); // Support both old and new format $since = $request->get('since'); if (!$since) { $since = date('Y-m-d H:i:s', current_time('timestamp') - 60); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date } else { $timestamp = strtotime($since); if (current_time('timestamp') - $timestamp > 300) { $since = date('Y-m-d H:i:s', current_time('timestamp') - 60); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date } } $feedUpdates = []; $hasChanges = false; // Get feed updates if since timestamp provided if ($since) { // Get all updated/created feeds with full data (including relationships) $currentUserModel = Helper::getCurrentUser(); $updatedFeeds = Feed::where('updated_at', '>', $since) ->where('status', 'published') ->byUserAccess($userId) ->with(Feed::withPublicRelations($currentUserModel, null)) ->orderBy('updated_at', 'desc') ->limit(20) // Reduced limit since we're sending full data ->get(); // Transform feeds to include all necessary data $transformedFeeds = FeedsHelper::transformFeedsCollection($updatedFeeds); foreach ($transformedFeeds as $feed) { $isNew = $feed->created_at >= $since; // Determine context (primary context) $context = 'global'; if ($feed->space_id && $feed->space) { $context = 'space-' . $feed->space->slug; } $feedUpdates[] = [ 'id' => $feed->id, 'updated_at' => $feed->updated_at, 'action' => $isNew ? 'created' : 'updated', 'context' => $context, 'user_id' => $feed->user_id, 'feed_data' => $feed // Include full feed data ]; } $hasChanges = !empty($feedUpdates); } // Get notification count $notificationCount = NotificationSubscriber::unread()->where('user_id', $userId)->count(); $newNotifications = $this->getToastNotifications($userId, $since, $notificationCount); $response = [ 'timestamp' => current_time('mysql'), 'has_changes' => $hasChanges, 'feeds' => $feedUpdates, 'notifications' => [ 'unread_count' => $notificationCount, 'new_count' => count($newNotifications), 'new_items' => $newNotifications ], 'spaces' => [], // For future use 'execution_time' => microtime(true) - $start ]; return apply_filters('fluent_community/feed_ticker', $response, $request->all()); } /** * Unread notifications that landed since the previous ticker check, shaped for the * in-app toast. Deliberately cheap: * * - returns before touching the DB when the toast is filtered off or the user has * nothing unread, so the steady state costs zero extra queries * - the predicate is answered by the (user_id, is_read, object_type, updated_at) * index added in NotificationUserMigrator, so this is a short range scan with * no filesort - on a 177k-row table it examines a single row instead of the * ~88k the single-column is_read index used to force * - the cursor is the subscriber `updated_at`, not `created_at`: a re-notification * ("X and 3 others reacted to your post") bumps the existing subscriber row in * place instead of inserting a new one - see NotificationEventHandler * - the xprofile eager load only fires when at least one row came back * * @param int $userId * @param string $since MySQL datetime in site local time * @param int $unreadCount * @return array */ protected function getToastNotifications($userId, $since, $unreadCount) { if (!$unreadCount || !$since) { return []; } if (!apply_filters('fluent_community/enable_notification_toast', true, $userId)) { return []; } $limit = (int)apply_filters('fluent_community/notification_toast_limit', 3, $userId); if ($limit < 1) { return []; } $notifications = Notification::query() ->select([ 'fcom_notifications.id', 'fcom_notifications.feed_id', 'fcom_notifications.object_id', 'fcom_notifications.src_user_id', 'fcom_notifications.action', 'fcom_notifications.content', 'fcom_notifications.route', 'fcom_notification_users.updated_at as notified_at' ]) ->join('fcom_notification_users', 'fcom_notification_users.object_id', '=', 'fcom_notifications.id') ->where('fcom_notification_users.user_id', $userId) ->where('fcom_notification_users.is_read', 0) ->where('fcom_notification_users.object_type', 'notification') ->where('fcom_notification_users.updated_at', '>', $since) ->with(['xprofile' => function ($q) { return $q->select(['user_id', 'display_name', 'username', 'avatar']); }]) ->orderBy('fcom_notification_users.updated_at', 'DESC') ->limit($limit) ->get(); $commentIds = []; foreach ($notifications as $notification) { if (!in_array($notification->action, PushNotificationModule::PUSHED_ACTIONS, true)) { continue; } $commentIds[] = (int)$notification->object_id; $commentIds[] = (int)Arr::get((array)$notification->route, 'query.comment_id'); } $pushedCommentIds = PushNotificationModule::getPushedCommentIds( $userId, array_values(array_filter(array_unique($commentIds))) ); $items = []; foreach ($notifications as $notification) { $wasPushed = in_array($notification->action, PushNotificationModule::PUSHED_ACTIONS, true) && (in_array((int)$notification->object_id, $pushedCommentIds, true) || in_array((int)Arr::get((array)$notification->route, 'query.comment_id'), $pushedCommentIds, true)); // The push already told this member; a toast would say it twice. if ($wasPushed) { continue; } $xprofile = $notification->xprofile; $items[] = [ 'id' => (int)$notification->id, 'feed_id' => $notification->feed_id ? (int)$notification->feed_id : null, 'object_id' => $notification->object_id ? (int)$notification->object_id : null, 'action' => $notification->action, 'route' => $notification->route, 'text' => $this->getToastText($notification->content), 'notified_at' => $notification->notified_at, 'avatar' => $xprofile ? $xprofile->avatar : '', 'name' => $xprofile ? $xprofile->display_name : '' ]; } return apply_filters('fluent_community/notification_toast_items', $items, $userId); } /** * Flatten stored notification HTML to a single line of plain text. The toast renders * this with v-text, so it must never carry markup back to the client. * * @param string $content * @return string */ protected function getToastText($content) { if (!$content) { return ''; } $text = wp_specialchars_decode(wp_strip_all_tags($content), ENT_QUOTES); $text = trim(preg_replace('/\s+/', ' ', $text)); if (mb_strlen($text) > 140) { $text = mb_substr($text, 0, 140) . '...'; } return $text; } public function batchFetch(Request $request) { $feedIds = $request->get('feed_ids', []); if (empty($feedIds) || !is_array($feedIds)) { return [ 'feeds' => [], 'error' => __('No feed IDs provided', 'fluent-community') ]; } $userId = get_current_user_id(); // Limit to 20 feeds per batch to prevent abuse $feedIds = array_slice($feedIds, 0, 20); // Build query based on context $query = Feed::whereIn('id', $feedIds) ->where('status', 'published') ->byUserAccess($userId); $currentUserModel = $this->getUser(); $feeds = $query ->with(Feed::withPublicRelations($currentUserModel)) ->get(); $feeds = FeedsHelper::transformFeedsCollection($feeds); return [ 'feeds' => $feeds, 'count' => $feeds->count() ]; } public function getTickerUpdates(Request $request) { $context = $request->get('context', 'global'); $since = $request->get('since'); // ISO 8601 timestamp $userId = get_current_user_id(); if (!$userId) { return [ 'updates' => [], 'timestamp' => current_time('mysql', true), 'has_changes' => false, 'error' => __('User not authenticated', 'fluent-community') ]; } // Parse since timestamp try { $sinceDate = $since ? new \DateTime($since) : null; } catch (\Exception $e) { return [ 'updates' => [], 'timestamp' => current_time('mysql', true), 'has_changes' => false, 'error' => __('Invalid timestamp format', 'fluent-community') ]; } // Build query based on context $query = Feed::query(); if (strpos($context, 'space-') === 0) { $spaceSlug = str_replace('space-', '', $context); $space = Space::where('slug', $spaceSlug)->first(); if ($space) { $query->where('space_id', $space->id); } } elseif (strpos($context, 'user-') === 0) { $targetUserId = str_replace('user-', '', $context); $query->where('user_id', $targetUserId); } // Apply access control $query->byUserAccess($userId); $updates = []; // Get updated feeds (updated_at changed) if ($sinceDate) { $updatedFeeds = (clone $query) ->where('updated_at', '>', $sinceDate->format('Y-m-d H:i:s')) ->where('status', 'published') ->select(['id', 'updated_at', 'created_at']) ->orderBy('updated_at', 'desc') ->limit(100) ->get(); foreach ($updatedFeeds as $feed) { $isNew = $feed->created_at >= $sinceDate->format('Y-m-d H:i:s'); $updates[] = [ 'id' => $feed->id, 'updated_at' => gmdate('c', strtotime($feed->updated_at)), 'action' => $isNew ? 'created' : 'updated' ]; } // Check for deleted feeds (status changed to deleted) $deletedFeeds = (clone $query) ->where('updated_at', '>', $sinceDate->format('Y-m-d H:i:s')) ->whereIn('status', ['deleted', 'draft']) ->select(['id', 'updated_at']) ->limit(50) ->get(); foreach ($deletedFeeds as $feed) { $updates[] = [ 'id' => $feed->id, 'updated_at' => gmdate('c', strtotime($feed->updated_at)), 'action' => 'deleted' ]; } } return [ 'updates' => $updates, 'timestamp' => current_time('mysql', true), 'has_changes' => !empty($updates) ]; } public function getOembed(Request $request) { $currentUser = $this->getUser(true); do_action('fluent_community/check_rate_limit/oembed', $currentUser); $url = $request->getSafe('url', 'sanitize_url'); $metaData = RemoteUrlParser::parse($url); if ($metaData && !is_wp_error($metaData)) { $data = [ 'oembed' => $metaData ]; return apply_filters('fluent_community/feed_oembed_api_response', $data, $request->all()); } return $this->sendError([ 'message' => __('No oembed data found', 'fluent-community'), 'url' => $url ]); } public function markdownToHtml(Request $request) { $message = CustomSanitizer::unslashMarkdown($request->get('text', '')); $html = wp_kses_post(FeedsHelper::mdToHtml($message)); $data = [ 'html' => $html ]; $data['message_rendered'] = $html; if (in_array('meta', $request->get('with', [])) && $request->get('feed')) { [$data,] = FeedsHelper::processFeedMetaData($data, $request->get('feed')); } return $data; } }