PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.10.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.10.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 1.1.0 All 77 releases
← All changes | app/Http/Controllers/FeedsController.php +250 -29 2.7.72.10.0 View file →
@@ -3,8 +3,9 @@
3 3 namespace FluentCommunity\App\Http\Controllers;
4 4
5 5 use FluentCommunity\App\Functions\Utility;
6 6 use FluentCommunity\App\Models\Media;
7 +use FluentCommunity\App\Models\Notification;
7 8 use FluentCommunity\App\Models\NotificationSubscriber;
8 9 use FluentCommunity\App\Models\Space;
9 10 use FluentCommunity\App\Models\User;
10 11 use FluentCommunity\App\Services\CustomSanitizer;
@@ -15,9 +16,11 @@
15 16 use FluentCommunity\App\Services\RemoteUrlParser;
16 17 use FluentCommunity\Framework\Http\Request\Request;
17 18 use FluentCommunity\App\Models\Feed;
18 19 use FluentCommunity\App\Models\BaseSpace;
20 +use FluentCommunity\App\Models\XProfile;
19 21 use FluentCommunity\Framework\Support\Arr;
22 +use FluentCommunity\Modules\PushNotification\PushNotificationModule;
20 23
21 24 class FeedsController extends Controller
22 25 {
23 26 public function get(Request $request)
@@ -36,23 +39,41 @@
36 39 }
37 40 }
38 41
39 42 $currentUserModel = $this->getUser();
43 + $currentUserId = get_current_user_id();
40 44
45 + $isOwnProfile = $userId && (int)$userId === (int)$currentUserId;
46 +
47 + $filterableStatuses = apply_filters('fluent_community/feed/filterable_statuses', []);
48 +
49 + $statusFilter = $request->getSafe('status', 'sanitize_text_field', '');
50 +
51 + $applyStatusFilter = $statusFilter
52 + && in_array($statusFilter, $filterableStatuses, true)
53 + && (Helper::isModerator() || $isOwnProfile);
54 +
55 + $maxPerPage = (int) apply_filters('fluent_community/max_per_page', 100) ?: 100;
56 +
41 57 $queryArgs = [
42 58 'selected_topic' => $selectedTopic,
43 - 'per_page' => (int)$request->get('per_page', 10),
44 - 'page' => (int)$request->get('page', 1),
59 + 'per_page' => min($maxPerPage, max(1, (int)$request->get('per_page', 10))),
60 + 'page' => max(1, (int)$request->get('page', 1)),
45 61 'search' => $search,
46 62 ];
47 63
48 - $feedsQuery = Feed::byContentModerationAccessStatus($currentUserModel, $space)
49 - ->select(Feed::$publicColumns)
64 + $feedsQuery = Feed::select(Feed::$publicColumns)
50 65 ->with(Feed::withPublicRelations($currentUserModel, $space))
51 66 ->searchBy($search, (array)$request->get('search_in', ['post_content']))
52 67 ->byTopicSlug($selectedTopic)
53 68 ->customOrderBy($request->getSafe('order_by_type'));
54 69
70 + if ($applyStatusFilter) {
71 + $feedsQuery->byStatus($statusFilter);
72 + } else {
73 + $feedsQuery->byContentModerationAccessStatus($currentUserModel, $space);
74 + }
75 +
55 76 $stickyFeed = null;
56 77
57 78 $disableSticky = $request->get('disable_sticky', '') == 'yes' || !!$search || !!$selectedTopic;
58 79
@@ -65,15 +86,15 @@
65 86 $feedsQuery = $feedsQuery->where('is_sticky', 0);
66 87 if ($queryArgs['page'] === 1) {
67 88 $stickyFeed = Feed::where('space_id', $space->id)
68 89 ->where('is_sticky', 1)
90 + ->byUserAccess($currentUserId)
91 + ->byContentModerationAccessStatus($currentUserModel, $space)
69 92 ->with(Feed::withPublicRelations($this->getUser(), $space))
70 93 ->first();
71 94 }
72 95 }
73 96
74 - $currentUserId = get_current_user_id();
75 -
76 97 if ($userId) {
77 98 $feedsQuery = $feedsQuery->where('user_id', $userId);
78 99
79 100 if (!Helper::isModerator()) {
@@ -171,9 +192,11 @@
171 192 'message' => __('The feed could not be found', 'fluent-community')
172 193 ], 404);
173 194 }
174 195
175 - if ($feed->status != 'published' && !$feed->hasEditAccess($this->getUserId())) {
196 + $viewableByLinkStatuses = FeedsHelper::getViewableByLinkStatuses();
197 +
198 + if (!in_array($feed->status, $viewableByLinkStatuses, true) && !$feed->hasEditAccess($this->getUserId())) {
176 199 return $this->sendError([
177 200 'message' => __('Sorry, you do not have permission to view this post', 'fluent-community')
178 201 ], 404);
179 202 }
@@ -260,8 +283,10 @@
260 283 $data = $this->sanitizeAndValidateData($requestData);
261 284 $data['user_id'] = $user->ID;
262 285 $data['status'] = 'published';
263 286
287 + $data['status'] = apply_filters('fluent_community/feed/save_status', $data['status'], $requestData, null);
288 +
264 289 $feed = new Feed();
265 290 $feed->user_id = $user->ID;
266 291 $space = null;
267 292
@@ -305,11 +330,9 @@
305 330
306 331 $spaceId = Arr::get($data, 'space_id');
307 332 $message = Arr::get($data, 'message');
308 333
309 - if ($isDulicate = $this->checkForDuplicatePost($user->ID, $message, $spaceId)) {
310 - return $isDulicate;
311 - }
334 + $duplicateCheckMessage = $message;
312 335
313 336 $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'), true);
314 337 if ($mentions) {
315 338 $data['message'] = $message;
@@ -354,12 +377,28 @@
354 377 ]);
355 378 }
356 379
357 380 $feed->fill($data);
358 - $feed->save();
359 381
382 + // Serialize a member's concurrent submissions by locking their profile row,
383 + // so parallel matching requests cannot pass the duplicate check and both insert.
384 + $isDuplicate = Helper::dbTransaction(function () use ($feed, $user, $spaceId, $duplicateCheckMessage) {
385 + XProfile::where('user_id', $user->ID)->lockForUpdate()->first();
386 +
387 + if ($duplicate = $this->checkForDuplicatePost($user->ID, $duplicateCheckMessage, $spaceId)) {
388 + return $duplicate;
389 + }
390 +
391 + $feed->save();
392 +
393 + return null;
394 + });
395 +
396 + if ($isDuplicate) {
397 + return $isDuplicate;
398 + }
399 +
360 400 $feed = Feed::find($feed->id); // just renewing the feed
361 - /** @var Feed $feed */
362 401
363 402 if ($mentions) {
364 403 do_action('fluent_community/feed_mentioned', $feed, Arr::get($mentions, 'users'));
365 404 }
@@ -397,9 +436,9 @@
397 436 'last_fetched_timestamp' => current_time('timestamp')
398 437 ];
399 438 }
400 439
401 - if ($feed->status != 'published') {
440 + if (!in_array($feed->status, ['published', 'unlisted'])) {
402 441 do_action('fluent_community/feed/new_feed_' . $feed->status, $feed);
403 442 /* translators: %s: The status of the post */
404 443 $message = sprintf(__('Your post has been marked as %s', 'fluent-community'), $feed->status);
405 444 return apply_filters('fluent_community/feed/new_feed_response', [
@@ -416,11 +455,13 @@
416 455 } else {
417 456 do_action('fluent_community/profile_feed/created', $feed);
418 457 }
419 458
459 + $message = __('Your post has been published', 'fluent-community');
460 +
420 461 return apply_filters('fluent_community/feed/new_feed_response', [
421 462 'feed' => FeedsHelper::transformFeed($feed),
422 - 'message' => __('Your post has been published', 'fluent-community'),
463 + 'message' => $message,
423 464 'last_fetched_timestamp' => current_time('timestamp')
424 465 ], $feed, $request->all());
425 466 }
426 467
@@ -441,8 +482,12 @@
441 482 }
442 483
443 484 $user->canEditFeed($existingFeed, true);
444 485
486 + // Must resolve before processFeedMetaData() reads it.
487 + $isModerator = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space);
488 + $requestData['is_admin'] = $isModerator;
489 +
445 490 if ($surveyOptionError = FeedsHelper::getSurveyOptionsUpdateError(
446 491 Arr::get($existingFeed->meta, 'survey_config.options', []),
447 492 Arr::get($requestData, 'survey', [])
448 493 )) {
@@ -450,11 +495,12 @@
450 495 'message' => $surveyOptionError
451 496 ]);
452 497 }
453 498
454 - if ($status = Arr::get($requestData, 'status')) {
455 - if (in_array($status, $editableStatuses)) {
456 - $data['status'] = $status;
499 + if ($isModerator && ($status = Arr::get($requestData, 'status'))) {
500 + if (in_array($status, $editableStatuses, true)) {
501 + $fallbackStatus = $status === 'unlisted' ? $existingFeed->status : $status;
502 + $data['status'] = apply_filters('fluent_community/feed/save_status', $fallbackStatus, $requestData, $existingFeed);
457 503 }
458 504 }
459 505
460 506 $message = $data['message'];
@@ -478,10 +524,8 @@
478 524 if (isset($existingFeed->meta['comments_disabled'])) {
479 525 $data['meta']['comments_disabled'] = $existingFeed->meta['comments_disabled'];
480 526 }
481 527
482 - $requestData['is_admin'] = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space);
483 -
484 528 if (Arr::get($requestData, 'send_announcement_email') == 'yes' && $requestData['is_admin']) {
485 529 $data['meta']['send_announcement_email'] = 'yes';
486 530 } else if (Arr::get($existingFeed->meta, 'send_announcement_email')) {
487 531 $data['meta']['send_announcement_email'] = Arr::get($existingFeed->meta, 'send_announcement_email');
@@ -524,8 +568,10 @@
524 568 'time' => current_time('mysql')
525 569 ];
526 570 }
527 571
572 + $movingToProfile = false;
573 +
528 574 if ($newSpaceId = $request->get('new_space_id')) {
529 575 if (!Helper::isUserInSpace($existingFeed->user_id, $newSpaceId)) {
530 576 return $this->sendError([
531 577 'message' => __('The author is not a member of the selected space', 'fluent-community')
@@ -552,8 +598,9 @@
552 598 ]);
553 599 }
554 600
555 601 $data['space_id'] = null;
602 + $movingToProfile = true;
556 603
557 604 \FluentCommunity\App\Models\Activity::where('feed_id', $existingFeed->id)
558 605 ->update(['space_id' => null]);
559 606 }
@@ -587,13 +634,20 @@
587 634 foreach ($mediaItems as $mediaItem) {
588 635 $mediaItemIds[] = $mediaItem->id;
589 636 }
590 637
591 - Media::where('object_source', 'feed')
592 - ->where('feed_id', $existingFeed->id)
593 - ->whereNotIn('id', $mediaItemIds)
594 - ->update(['is_active' => 0]);
638 + if (Arr::has($requestData, 'media_images')) {
639 + $deactivateQuery = Media::where('object_source', 'feed')
640 + ->where('feed_id', $existingFeed->id)
641 + ->whereNotIn('id', $mediaItemIds);
595 642
643 + if (empty(Arr::get($requestData, 'media_images'))) {
644 + $deactivateQuery->where('media_type', '!=', 'fluent_player');
645 + }
646 +
647 + $deactivateQuery->update(['is_active' => 0]);
648 + }
649 +
596 650 if ($mediaItems) {
597 651 $this->saveMediaItems($existingFeed, $mediaItems);
598 652 }
599 653
@@ -612,8 +666,11 @@
612 666 if ($space && Arr::get($space->settings, 'topic_required') != 'yes') {
613 667 $existingFeed->terms()->where('taxonomy_name', 'post_topic')->detach();
614 668 }
615 669 }
670 + } else if ($movingToProfile) {
671 + // Topics are space-scoped; a post moved to the profile must not keep them.
672 + $existingFeed->terms()->where('taxonomy_name', 'post_topic')->detach();
616 673 }
617 674
618 675 if ($dirty) {
619 676 do_action('fluent_community/feed/updated', $existingFeed, $dirty);
@@ -655,14 +712,25 @@
655 712 $data = Arr::only($allData, $validKeys);
656 713
657 714 $data = array_map('intval', $data);
658 715
716 + // List/unlist toggle — community-moderator only, routed through the shared save_status filter.
717 + if (Helper::isModerator($user)
718 + && ($reqStatus = Arr::get($allData, 'status'))
719 + && in_array($reqStatus, ['published', 'unlisted'], true)
720 + && in_array($feed->status, ['published', 'unlisted'], true)
721 + ) {
722 + $fallbackStatus = $reqStatus === 'unlisted' ? $feed->status : $reqStatus;
723 + $data['status'] = apply_filters('fluent_community/feed/save_status', $fallbackStatus, $allData, $feed);
724 + }
725 +
659 726 if (isset($data['is_sticky'])) {
660 727 $data['is_sticky'] = $data['is_sticky'] ? 1 : 0;
661 728 if ($data['is_sticky'] && $feed->space_id) {
662 - // remove all the sticky posts from the space
729 + // toBase() keeps the type scope but skips the Orm update()'s updated_at stamp, which would bump the post being un-stuck.
663 730 Feed::where('space_id', $feed->space_id)
664 731 ->where('is_sticky', 1)
732 + ->toBase()
665 733 ->update(['is_sticky' => 0]);
666 734 }
667 735 }
668 736
@@ -675,8 +743,13 @@
675 743 if ($data) {
676 744 $feed->fill($data);
677 745 $dirty = $feed->getDirty();
678 746 if ($dirty) {
747 + // Only a real list/unlist transition is activity, so read $dirty, not the request.
748 + if (!array_key_exists('status', $dirty)) {
749 + $feed->timestamps = false;
750 + }
751 +
679 752 $feed->save();
680 753 do_action('fluent_community/feed/updated', $feed, $dirty);
681 754 }
682 755 }
@@ -883,10 +956,11 @@
883 956 $allowedFileSize = $maxFileSize * 1024 * 1024;
884 957 }
885 958
886 959 $files = $this->validate($this->request->files(), [
887 - 'file' => 'mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize,
960 + 'file' => 'required|mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize,
888 961 ], [
962 + 'file.required' => __('No upload file was received. Please try again.', 'fluent-community'),
889 963 'file.mimetypes' => __('The file must be an image type.', 'fluent-community'),
890 964 /* translators: %$1s is replaced by the maximum allowed file size, %2$s is replaced by the file size unit (e.g. MB) */
891 965 'file.max' => sprintf(__('The file size must be less than %1$s%2$s.', 'fluent-community'), $maxFileSize, $maxFileUnit)
892 966 ]);
@@ -902,10 +976,23 @@
902 976 add_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
903 977 $uploadedFiles = FileSystem::put($files);
904 978 remove_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
905 979
906 - $file = $uploadedFiles[0];
980 + $file = Arr::get($uploadedFiles, 0);
907 981
982 + if (is_wp_error($file)) {
983 + return $this->sendError([
984 + 'message' => $file->get_error_message()
985 + ]);
986 + }
987 +
988 + // an empty request body reaches here with nothing uploaded; never build media data from it
989 + if (!is_array($file) || empty($file['url']) || empty($file['file']) || empty($file['type'])) {
990 + return $this->sendError([
991 + 'message' => __('No upload file was received. Please try again.', 'fluent-community')
992 + ]);
993 + }
994 +
908 995 $upload_dir = wp_upload_dir();
909 996
910 997 $originalUrl = $file['url'];
911 998 $orginalPath = $upload_dir['basedir'] . '/fluent-community/' . $file['file'];
@@ -1119,8 +1206,10 @@
1119 1206
1120 1207 // Get notification count
1121 1208 $notificationCount = NotificationSubscriber::unread()->where('user_id', $userId)->count();
1122 1209
1210 + $newNotifications = $this->getToastNotifications($userId, $since, $notificationCount);
1211 +
1123 1212 $response = [
1124 1213 'timestamp' => current_time('mysql'),
1125 1214 'has_changes' => $hasChanges,
1126 1215 'feeds' => $feedUpdates,
@@ -1125,9 +1214,10 @@
1125 1214 'has_changes' => $hasChanges,
1126 1215 'feeds' => $feedUpdates,
1127 1216 'notifications' => [
1128 1217 'unread_count' => $notificationCount,
1129 - 'new_count' => 0 // Could track new since last check
1218 + 'new_count' => count($newNotifications),
1219 + 'new_items' => $newNotifications
1130 1220 ],
1131 1221 'spaces' => [], // For future use
1132 1222 'execution_time' => microtime(true) - $start
1133 1223 ];
@@ -1134,8 +1224,135 @@
1134 1224
1135 1225 return apply_filters('fluent_community/feed_ticker', $response, $request->all());
1136 1226 }
1137 1227
1228 + /**
1229 + * Unread notifications that landed since the previous ticker check, shaped for the
1230 + * in-app toast. Deliberately cheap:
1231 + *
1232 + * - returns before touching the DB when the toast is filtered off or the user has
1233 + * nothing unread, so the steady state costs zero extra queries
1234 + * - the predicate is answered by the (user_id, is_read, object_type, updated_at)
1235 + * index added in NotificationUserMigrator, so this is a short range scan with
1236 + * no filesort - on a 177k-row table it examines a single row instead of the
1237 + * ~88k the single-column is_read index used to force
1238 + * - the cursor is the subscriber `updated_at`, not `created_at`: a re-notification
1239 + * ("X and 3 others reacted to your post") bumps the existing subscriber row in
1240 + * place instead of inserting a new one - see NotificationEventHandler
1241 + * - the xprofile eager load only fires when at least one row came back
1242 + *
1243 + * @param int $userId
1244 + * @param string $since MySQL datetime in site local time
1245 + * @param int $unreadCount
1246 + * @return array
1247 + */
1248 + protected function getToastNotifications($userId, $since, $unreadCount)
1249 + {
1250 + if (!$unreadCount || !$since) {
1251 + return [];
1252 + }
1253 +
1254 + if (!apply_filters('fluent_community/enable_notification_toast', true, $userId)) {
1255 + return [];
1256 + }
1257 +
1258 + $limit = (int)apply_filters('fluent_community/notification_toast_limit', 3, $userId);
1259 +
1260 + if ($limit < 1) {
1261 + return [];
1262 + }
1263 +
1264 + $notifications = Notification::query()
1265 + ->select([
1266 + 'fcom_notifications.id',
1267 + 'fcom_notifications.feed_id',
1268 + 'fcom_notifications.object_id',
1269 + 'fcom_notifications.src_user_id',
1270 + 'fcom_notifications.action',
1271 + 'fcom_notifications.content',
1272 + 'fcom_notifications.route',
1273 + 'fcom_notification_users.updated_at as notified_at'
1274 + ])
1275 + ->join('fcom_notification_users', 'fcom_notification_users.object_id', '=', 'fcom_notifications.id')
1276 + ->where('fcom_notification_users.user_id', $userId)
1277 + ->where('fcom_notification_users.is_read', 0)
1278 + ->where('fcom_notification_users.object_type', 'notification')
1279 + ->where('fcom_notification_users.updated_at', '>', $since)
1280 + ->with(['xprofile' => function ($q) {
1281 + return $q->select(['user_id', 'display_name', 'username', 'avatar']);
1282 + }])
1283 + ->orderBy('fcom_notification_users.updated_at', 'DESC')
1284 + ->limit($limit)
1285 + ->get();
1286 +
1287 + $commentIds = [];
1288 + foreach ($notifications as $notification) {
1289 + if (!in_array($notification->action, PushNotificationModule::PUSHED_ACTIONS, true)) {
1290 + continue;
1291 + }
1292 +
1293 + $commentIds[] = (int)$notification->object_id;
1294 + $commentIds[] = (int)Arr::get((array)$notification->route, 'query.comment_id');
1295 + }
1296 +
1297 + $pushedCommentIds = PushNotificationModule::getPushedCommentIds(
1298 + $userId,
1299 + array_values(array_filter(array_unique($commentIds)))
1300 + );
1301 +
1302 + $items = [];
1303 +
1304 + foreach ($notifications as $notification) {
1305 + $wasPushed = in_array($notification->action, PushNotificationModule::PUSHED_ACTIONS, true)
1306 + && (in_array((int)$notification->object_id, $pushedCommentIds, true)
1307 + || in_array((int)Arr::get((array)$notification->route, 'query.comment_id'), $pushedCommentIds, true));
1308 +
1309 + // The push already told this member; a toast would say it twice.
1310 + if ($wasPushed) {
1311 + continue;
1312 + }
1313 +
1314 + $xprofile = $notification->xprofile;
1315 +
1316 + $items[] = [
1317 + 'id' => (int)$notification->id,
1318 + 'feed_id' => $notification->feed_id ? (int)$notification->feed_id : null,
1319 + 'object_id' => $notification->object_id ? (int)$notification->object_id : null,
1320 + 'action' => $notification->action,
1321 + 'route' => $notification->route,
1322 + 'text' => $this->getToastText($notification->content),
1323 + 'notified_at' => $notification->notified_at,
1324 + 'avatar' => $xprofile ? $xprofile->avatar : '',
1325 + 'name' => $xprofile ? $xprofile->display_name : ''
1326 + ];
1327 + }
1328 +
1329 + return apply_filters('fluent_community/notification_toast_items', $items, $userId);
1330 + }
1331 +
1332 + /**
1333 + * Flatten stored notification HTML to a single line of plain text. The toast renders
1334 + * this with v-text, so it must never carry markup back to the client.
1335 + *
1336 + * @param string $content
1337 + * @return string
1338 + */
1339 + protected function getToastText($content)
1340 + {
1341 + if (!$content) {
1342 + return '';
1343 + }
1344 +
1345 + $text = wp_specialchars_decode(wp_strip_all_tags($content), ENT_QUOTES);
1346 + $text = trim(preg_replace('/\s+/', ' ', $text));
1347 +
1348 + if (mb_strlen($text) > 140) {
1349 + $text = mb_substr($text, 0, 140) . '...';
1350 + }
1351 +
1352 + return $text;
1353 + }
1354 +
1138 1355 public function batchFetch(Request $request)
1139 1356 {
1140 1357 $feedIds = $request->get('feed_ids', []);
1141 1358
@@ -1261,10 +1478,14 @@
1261 1478 }
1262 1479
1263 1480 public function getOembed(Request $request)
1264 1481 {
1265 - $url = $request->get('url');
1266 - // check if the url is valid
1482 + $currentUser = $this->getUser(true);
1483 +
1484 + do_action('fluent_community/check_rate_limit/oembed', $currentUser);
1485 +
1486 + $url = $request->getSafe('url', 'sanitize_url');
1487 +
1267 1488 $metaData = RemoteUrlParser::parse($url);
1268 1489
1269 1490 if ($metaData && !is_wp_error($metaData)) {
1270 1491 $data = [