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 +194 -15 2.8.12.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)
@@ -189,9 +192,9 @@
189 192 'message' => __('The feed could not be found', 'fluent-community')
190 193 ], 404);
191 194 }
192 195
193 - $viewableByLinkStatuses = ['published', 'unlisted'];
196 + $viewableByLinkStatuses = FeedsHelper::getViewableByLinkStatuses();
194 197
195 198 if (!in_array($feed->status, $viewableByLinkStatuses, true) && !$feed->hasEditAccess($this->getUserId())) {
196 199 return $this->sendError([
197 200 'message' => __('Sorry, you do not have permission to view this post', 'fluent-community')
@@ -327,11 +330,9 @@
327 330
328 331 $spaceId = Arr::get($data, 'space_id');
329 332 $message = Arr::get($data, 'message');
330 333
331 - if ($isDulicate = $this->checkForDuplicatePost($user->ID, $message, $spaceId)) {
332 - return $isDulicate;
333 - }
334 + $duplicateCheckMessage = $message;
334 335
335 336 $mentions = FeedsHelper::getMentions($data['message'], Arr::get($data, 'space_id'), true);
336 337 if ($mentions) {
337 338 $data['message'] = $message;
@@ -376,12 +377,28 @@
376 377 ]);
377 378 }
378 379
379 380 $feed->fill($data);
380 - $feed->save();
381 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 +
382 400 $feed = Feed::find($feed->id); // just renewing the feed
383 - /** @var Feed $feed */
384 401
385 402 if ($mentions) {
386 403 do_action('fluent_community/feed_mentioned', $feed, Arr::get($mentions, 'users'));
387 404 }
@@ -465,8 +482,12 @@
465 482 }
466 483
467 484 $user->canEditFeed($existingFeed, true);
468 485
486 + // Must resolve before processFeedMetaData() reads it.
487 + $isModerator = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space);
488 + $requestData['is_admin'] = $isModerator;
489 +
469 490 if ($surveyOptionError = FeedsHelper::getSurveyOptionsUpdateError(
470 491 Arr::get($existingFeed->meta, 'survey_config.options', []),
471 492 Arr::get($requestData, 'survey', [])
472 493 )) {
@@ -474,9 +495,9 @@
474 495 'message' => $surveyOptionError
475 496 ]);
476 497 }
477 498
478 - if ($status = Arr::get($requestData, 'status')) {
499 + if ($isModerator && ($status = Arr::get($requestData, 'status'))) {
479 500 if (in_array($status, $editableStatuses, true)) {
480 501 $fallbackStatus = $status === 'unlisted' ? $existingFeed->status : $status;
481 502 $data['status'] = apply_filters('fluent_community/feed/save_status', $fallbackStatus, $requestData, $existingFeed);
482 503 }
@@ -503,10 +524,8 @@
503 524 if (isset($existingFeed->meta['comments_disabled'])) {
504 525 $data['meta']['comments_disabled'] = $existingFeed->meta['comments_disabled'];
505 526 }
506 527
507 - $requestData['is_admin'] = $user->hasPermissionOrInCurrentSpace('community_moderator', $existingFeed->space);
508 -
509 528 if (Arr::get($requestData, 'send_announcement_email') == 'yes' && $requestData['is_admin']) {
510 529 $data['meta']['send_announcement_email'] = 'yes';
511 530 } else if (Arr::get($existingFeed->meta, 'send_announcement_email')) {
512 531 $data['meta']['send_announcement_email'] = Arr::get($existingFeed->meta, 'send_announcement_email');
@@ -549,8 +568,10 @@
549 568 'time' => current_time('mysql')
550 569 ];
551 570 }
552 571
572 + $movingToProfile = false;
573 +
553 574 if ($newSpaceId = $request->get('new_space_id')) {
554 575 if (!Helper::isUserInSpace($existingFeed->user_id, $newSpaceId)) {
555 576 return $this->sendError([
556 577 'message' => __('The author is not a member of the selected space', 'fluent-community')
@@ -577,8 +598,9 @@
577 598 ]);
578 599 }
579 600
580 601 $data['space_id'] = null;
602 + $movingToProfile = true;
581 603
582 604 \FluentCommunity\App\Models\Activity::where('feed_id', $existingFeed->id)
583 605 ->update(['space_id' => null]);
584 606 }
@@ -644,8 +666,11 @@
644 666 if ($space && Arr::get($space->settings, 'topic_required') != 'yes') {
645 667 $existingFeed->terms()->where('taxonomy_name', 'post_topic')->detach();
646 668 }
647 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();
648 673 }
649 674
650 675 if ($dirty) {
651 676 do_action('fluent_community/feed/updated', $existingFeed, $dirty);
@@ -700,11 +725,12 @@
700 725
701 726 if (isset($data['is_sticky'])) {
702 727 $data['is_sticky'] = $data['is_sticky'] ? 1 : 0;
703 728 if ($data['is_sticky'] && $feed->space_id) {
704 - // 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.
705 730 Feed::where('space_id', $feed->space_id)
706 731 ->where('is_sticky', 1)
732 + ->toBase()
707 733 ->update(['is_sticky' => 0]);
708 734 }
709 735 }
710 736
@@ -717,8 +743,13 @@
717 743 if ($data) {
718 744 $feed->fill($data);
719 745 $dirty = $feed->getDirty();
720 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 +
721 752 $feed->save();
722 753 do_action('fluent_community/feed/updated', $feed, $dirty);
723 754 }
724 755 }
@@ -925,10 +956,11 @@
925 956 $allowedFileSize = $maxFileSize * 1024 * 1024;
926 957 }
927 958
928 959 $files = $this->validate($this->request->files(), [
929 - 'file' => 'mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize,
960 + 'file' => 'required|mimetypes:' . $allowedTypes . '|max:' . $allowedFileSize,
930 961 ], [
962 + 'file.required' => __('No upload file was received. Please try again.', 'fluent-community'),
931 963 'file.mimetypes' => __('The file must be an image type.', 'fluent-community'),
932 964 /* translators: %$1s is replaced by the maximum allowed file size, %2$s is replaced by the file size unit (e.g. MB) */
933 965 'file.max' => sprintf(__('The file size must be less than %1$s%2$s.', 'fluent-community'), $maxFileSize, $maxFileUnit)
934 966 ]);
@@ -944,10 +976,23 @@
944 976 add_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
945 977 $uploadedFiles = FileSystem::put($files);
946 978 remove_filter('wp_handle_upload', [UploadHelper::class, 'fixImageOrientation']);
947 979
948 - $file = $uploadedFiles[0];
980 + $file = Arr::get($uploadedFiles, 0);
949 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 +
950 995 $upload_dir = wp_upload_dir();
951 996
952 997 $originalUrl = $file['url'];
953 998 $orginalPath = $upload_dir['basedir'] . '/fluent-community/' . $file['file'];
@@ -1161,8 +1206,10 @@
1161 1206
1162 1207 // Get notification count
1163 1208 $notificationCount = NotificationSubscriber::unread()->where('user_id', $userId)->count();
1164 1209
1210 + $newNotifications = $this->getToastNotifications($userId, $since, $notificationCount);
1211 +
1165 1212 $response = [
1166 1213 'timestamp' => current_time('mysql'),
1167 1214 'has_changes' => $hasChanges,
1168 1215 'feeds' => $feedUpdates,
@@ -1167,9 +1214,10 @@
1167 1214 'has_changes' => $hasChanges,
1168 1215 'feeds' => $feedUpdates,
1169 1216 'notifications' => [
1170 1217 'unread_count' => $notificationCount,
1171 - 'new_count' => 0 // Could track new since last check
1218 + 'new_count' => count($newNotifications),
1219 + 'new_items' => $newNotifications
1172 1220 ],
1173 1221 'spaces' => [], // For future use
1174 1222 'execution_time' => microtime(true) - $start
1175 1223 ];
@@ -1176,8 +1224,135 @@
1176 1224
1177 1225 return apply_filters('fluent_community/feed_ticker', $response, $request->all());
1178 1226 }
1179 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 +
1180 1355 public function batchFetch(Request $request)
1181 1356 {
1182 1357 $feedIds = $request->get('feed_ids', []);
1183 1358
@@ -1303,10 +1478,14 @@
1303 1478 }
1304 1479
1305 1480 public function getOembed(Request $request)
1306 1481 {
1307 - $url = $request->get('url');
1308 - // 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 +
1309 1488 $metaData = RemoteUrlParser::parse($url);
1310 1489
1311 1490 if ($metaData && !is_wp_error($metaData)) {
1312 1491 $data = [