PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.11.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.11.0
2.11.0 2.10.0 2.10.01 2.9.1 2.9.0 2.8.1 2.8.0 2.7.7 2.7.5 2.7.0 2.6.01 2.6.0 2.5.0 2.4.01 trunk 1.0.90 1.0.91 1.0.92 1.0.93 1.0.94 1.0.95 1.0.96 1.0.97 1.0.98 1.0.99 All 78 releases
← All changes | app/Services/FeedsHelper.php +287 -27 2.5.02.11.0 View file →
@@ -2,16 +2,16 @@
2 2
3 3 namespace FluentCommunity\App\Services;
4 4
5 5 use FluentCommunity\App\Functions\Utility;
6 -use \FluentCommunity\App\Models\Space;
6 +use FluentCommunity\App\Models\BaseSpace;
7 7 use FluentCommunity\App\Models\Feed;
8 8 use FluentCommunity\App\Models\Media;
9 -use FluentCommunity\App\Models\Comment;
10 9 use FluentCommunity\App\Models\Reaction;
11 10 use FluentCommunity\App\Models\Term;
12 11 use FluentCommunity\App\Models\User;
13 12 use FluentCommunity\App\Models\XProfile;
13 +use FluentCommunity\Framework\Foundation\Exceptions\UnprocessableEntityHttpException;
14 14 use FluentCommunity\Framework\Support\Arr;
15 15 use FluentCommunity\Framework\Validator\Validator;
16 16
17 17 class FeedsHelper
@@ -27,8 +27,25 @@
27 27 {
28 28 return array_values(array_unique(self::$currentRelatedUserIds));
29 29 }
30 30
31 + /**
32 + * Resolve who should receive the "post author" notification for a feed.
33 + * Course lessons notify the COURSE creator (whoever created the course),
34 + * not the user who uploaded the individual lesson.
35 + */
36 + public static function getNotificationAuthorId($feed)
37 + {
38 + if ($feed->type === 'course_lesson' && $feed->space_id) {
39 + $course = BaseSpace::withoutGlobalScopes()->find($feed->space_id);
40 + if ($course && $course->created_by) {
41 + return (int) $course->created_by;
42 + }
43 + }
44 +
45 + return (int) $feed->user_id;
46 + }
47 +
31 48 public static function getSpaceSlugsByUserId($userId)
32 49 {
33 50 if (!$userId) {
34 51 $userId = get_current_user_id();
@@ -42,8 +59,71 @@
42 59
43 60 return $user->spaces()->pluck('slug')->toArray();
44 61 }
45 62
63 + /**
64 + * Statuses where a post is fully reachable by its direct link. An unlisted post is
65 + * hidden from listings only, so it stays commentable and reactable like a published one.
66 + *
67 + * @return array
68 + */
69 + public static function getViewableByLinkStatuses()
70 + {
71 + return ['published', 'unlisted'];
72 + }
73 +
74 + /**
75 + * Row types that opt IN to comments through meta.enable_comments, mapped to the value
76 + * assumed when the key is absent.
77 + *
78 + * A feed post uses the opposite convention - meta.comments_disabled, absent meaning on -
79 + * so it is deliberately not listed here and falls through to the permissive default.
80 + *
81 + * The fallbacks match each model's getDefaultMeta(): a lesson written before the
82 + * setting existed keeps its thread, a page does not. Guessing one value for both
83 + * would silently switch off every legacy lesson discussion.
84 + *
85 + * @return array<string, string>
86 + */
87 + public static function getOptInCommentTypes()
88 + {
89 + return apply_filters('fluent_community/opt_in_comment_types', [
90 + 'course_lesson' => 'yes',
91 + 'space_page' => 'no',
92 + ]);
93 + }
94 +
95 + /**
96 + * Whether a row accepts comments at all, by its own settings.
97 + *
98 + * This is the setting check only - it says nothing about who the current user is.
99 + * Space membership and the course level kill switch are separate, in
100 + * CommentsController::verifySpacePermission().
101 + *
102 + * Both the read and the write path go through here so they cannot disagree. They used
103 + * to: the write path only ever read meta.comments_disabled, which pages and lessons
104 + * do not set, so a POST landed a comment on a page whose thread the UI was hiding.
105 + *
106 + * @param \FluentCommunity\App\Models\Feed $feed
107 + * @return bool
108 + */
109 + public static function commentsEnabled($feed)
110 + {
111 + $meta = $feed->meta;
112 +
113 + if (Arr::get($meta, 'comments_disabled') === 'yes') {
114 + return false;
115 + }
116 +
117 + $optIn = self::getOptInCommentTypes();
118 +
119 + if (isset($optIn[$feed->type])) {
120 + return Arr::get($meta, 'enable_comments', $optIn[$feed->type]) === 'yes';
121 + }
122 +
123 + return true;
124 + }
125 +
46 126 public static function getLastFeedId()
47 127 {
48 128 $lastItem = Feed::where('status', 'published')
49 129 ->byUserAccess(get_current_user_id())
@@ -107,8 +187,25 @@
107 187 'code' => array(),
108 188 'pre' => array(),
109 189 'blockquote' => array(),
110 190 'del' => array(),
191 + 'table' => array(),
192 + 'thead' => array(),
193 + 'tbody' => array(),
194 + 'tfoot' => array(),
195 + 'tr' => array(),
196 + 'th' => array(
197 + 'align' => true,
198 + 'style' => true,
199 + 'colspan' => true,
200 + 'rowspan' => true,
201 + ),
202 + 'td' => array(
203 + 'align' => true,
204 + 'style' => true,
205 + 'colspan' => true,
206 + 'rowspan' => true,
207 + ),
111 208 ));
112 209
113 210 return self::maybeTransformDynamicCodes($html);
114 211 }
@@ -216,15 +313,27 @@
216 313 }
217 314
218 315 public static function findFirstUrl($html)
219 316 {
220 - // use regular expression to find the first URL in a href tag
221 - // do not take the url which contains /u/ in it
222 - $pattern = '/<a\s+(?:[^>]*?\s+)?href=([\'"])(?!.*\/u\/)(.*?)\1/';
223 - preg_match($pattern, $html, $matches);
317 + if (!preg_match_all('/<a\s+(?:[^>]*?\s+)?href=([\'"])(.*?)\1/i', $html, $matches)) {
318 + return '';
319 + }
224 320
225 - if (isset($matches[2])) {
226 - return $matches[2];
321 + $profileUrlPrefix = Helper::baseUrl('u/');
322 +
323 + foreach ($matches[2] as $href) {
324 + // Rendered HTML encodes "&" as "&amp;". Left encoded, "?a=1&amp;b=2" is read
325 + // as a parameter named "amp;b" — which makes YouTube drop the "list" param.
326 + // Re-sanitized because decoding also restores quotes and angle brackets,
327 + // and this value is fetched remotely and stored on the feed.
328 + $href = sanitize_url(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
329 +
330 + // sanitize_url() empties a disallowed scheme. Returning that would report
331 + // "no links" for the whole post and skip any later, usable link.
332 + if (!$href || strpos($href, $profileUrlPrefix) === 0) {
333 + continue;
334 + }
335 + return $href;
227 336 }
228 337
229 338 return '';
230 339 }
@@ -407,9 +516,9 @@
407 516 **/
408 517 public static function createFeed($allData)
409 518 {
410 519 if (!is_array($allData)) {
411 - return new \WP_Error('invalid_data', 'Invalid data. The data need to be array', ['status' => 400]);
520 + return new \WP_Error('invalid_data', __('Invalid data. The data need to be array', 'fluent-community'), ['status' => 400]);
412 521 }
413 522
414 523 $acceptedKeys = ['message', 'title', 'user_id', 'space_id', 'type'];
415 524 $feedData = Arr::only($allData, $acceptedKeys);
@@ -422,9 +531,9 @@
422 531 'space_id' => 'nullable|integer|exists:fcom_spaces,id'
423 532 ]);
424 533
425 534 if ($validation->fails()) {
426 - return new \WP_Error('validation_failed', 'Validation failed', $validation->errors());
535 + return new \WP_Error('validation_failed', __('Validation failed', 'fluent-community'), $validation->errors());
427 536 }
428 537
429 538 $sanitizedData = self::sanitizeAndValidateData($feedData);
430 539
@@ -432,13 +541,13 @@
432 541
433 542 $user = User::find($feedData['user_id']);
434 543
435 544 if (!$user) {
436 - return new \WP_Error('user_not_found', 'User not found', ['status' => 400]);
545 + return new \WP_Error('user_not_found', __('User not found', 'fluent-community'), ['status' => 400]);
437 546 }
438 547 $user->syncXProfile();
439 548 if ($user->xprofile->status != 'active') {
440 - return new \WP_Error('user_inactive', 'User status is not active', $validation->errors());
549 + return new \WP_Error('user_inactive', __('User status is not active', 'fluent-community'), $validation->errors());
441 550 }
442 551
443 552 $markdown = $feedData['message'];
444 553 $mentions = null;
@@ -445,9 +554,9 @@
445 554
446 555 // Extra Validaton for space_id
447 556 if (!empty($feedData['space_id'])) {
448 557 if (!Helper::isUserInSpace($feedData['user_id'], $feedData['space_id'])) {
449 - return new \WP_Error('invalid_space', 'User is not in the space', ['status' => 400]);
558 + return new \WP_Error('invalid_space', __('User is not in the space', 'fluent-community'), ['status' => 400]);
450 559 }
451 560 $mentions = FeedsHelper::getMentions($markdown, Arr::get($feedData, 'space_id'), true);
452 561 if ($mentions) {
453 562 $markdown = $mentions['text'];
@@ -469,8 +578,13 @@
469 578 $feedData['meta']['mentioned_user_ids'] = Arr::get($mentions, 'user_ids', []);
470 579 }
471 580
472 581 $data = apply_filters('fluent_community/feed/new_feed_data', $feedData, $allData);
582 +
583 + if (is_wp_error($data)) {
584 + return $data;
585 + }
586 +
473 587 $feed = new Feed();
474 588 $feed->fill($data);
475 589 $feed->save();
476 590
@@ -497,9 +611,9 @@
497 611 }
498 612
499 613 public static function sanitizeAndValidateData($data)
500 614 {
501 - $message = CustomSanitizer::unslashMarkdown(trim(Arr::get($data, 'message')));
615 + $message = CustomSanitizer::unslashMarkdown(trim((string) Arr::get($data, 'message', '')));
502 616
503 617 // Decode HTML entities and strip all whitespace for validation
504 618 $messageForValidation = html_entity_decode($message, ENT_QUOTES | ENT_HTML5, 'UTF-8');
505 619 $messageForValidation = preg_replace('/\s+/u', '', $messageForValidation);
@@ -504,9 +618,12 @@
504 618 $messageForValidation = html_entity_decode($message, ENT_QUOTES | ENT_HTML5, 'UTF-8');
505 619 $messageForValidation = preg_replace('/\s+/u', '', $messageForValidation);
506 620
507 621 if (!$messageForValidation) {
508 - throw new \Exception(esc_html__('Message is required', 'fluent-community'));
622 + throw new UnprocessableEntityHttpException(
623 + esc_html__('Message is required', 'fluent-community'),
624 + 'feed_message_required'
625 + );
509 626 }
510 627
511 628 $processedData = [
512 629 'message' => $message,
@@ -524,9 +641,9 @@
524 641 }
525 642
526 643 $formattedOptions[] = [
527 644 'label' => sanitize_text_field($option['label']),
528 - 'slug' => 'opt_' . ($index + 1)
645 + 'slug' => Arr::get($option, 'slug') ?: 'opt_' . ($index + 1)
529 646 ];
530 647 }
531 648
532 649 $endDate = Arr::get($survey, 'end_date', '');
@@ -546,10 +663,13 @@
546 663 }
547 664
548 665 $maxlen = apply_filters('fluent_community/max_post_length', 15000);
549 666 if (\strlen($message) > $maxlen) {
550 - /* translators: %s is the maximum allowed character count */
551 - throw new \Exception(esc_html(sprintf(__('The post is too long. Please keep it under %s characters.', 'fluent-community'), number_format($maxlen))));
667 + throw new UnprocessableEntityHttpException(
668 + /* translators: %s is the maximum allowed character count */
669 + esc_html(sprintf(__('The post is too long. Please keep it under %s characters.', 'fluent-community'), number_format($maxlen))),
670 + 'feed_message_too_long'
671 + );
552 672 }
553 673
554 674 $titlePref = Utility::postTitlePref();
555 675
@@ -555,13 +675,16 @@
555 675
556 676 if ($titlePref) {
557 677 $processedData['title'] = sanitize_text_field(Arr::get($data, 'title'));
558 678 if ($titlePref == 'required' && empty($processedData['title'])) {
559 - throw new \Exception(esc_html__('Title is required. Please provide a title', 'fluent-community'));
679 + throw new UnprocessableEntityHttpException(
680 + esc_html__('Title is required. Please provide a title', 'fluent-community'),
681 + 'feed_title_required'
682 + );
560 683 }
561 - // trim the title if it's too long to 150 char
562 - if (\strlen($processedData['title']) > 192) {
563 - $processedData['title'] = substr($processedData['title'], 0, 192);
684 + // trim the title if it's too long to 192 chars (multibyte-safe; column is VARCHAR(192) characters)
685 + if (mb_strlen($processedData['title']) > 192) {
686 + $processedData['title'] = mb_substr($processedData['title'], 0, 192, 'UTF-8');
564 687 }
565 688 }
566 689
567 690 return $processedData;
@@ -566,8 +689,36 @@
566 689
567 690 return $processedData;
568 691 }
569 692
693 + public static function getSurveyOptionsUpdateError($existingSurveyOptions, $submittedSurvey)
694 + {
695 + if (empty($existingSurveyOptions) || empty($submittedSurvey)) {
696 + return null;
697 + }
698 +
699 + $submittedLabelsBySlug = [];
700 + foreach (Arr::get($submittedSurvey, 'options', []) as $option) {
701 + $slug = Arr::get($option, 'slug', '');
702 + if ($slug !== '') {
703 + $submittedLabelsBySlug[$slug] = trim((string)Arr::get($option, 'label', ''));
704 + }
705 + }
706 +
707 + foreach ($existingSurveyOptions as $existingOption) {
708 + $slug = Arr::get($existingOption, 'slug', '');
709 + if ($slug === '') {
710 + continue;
711 + }
712 +
713 + if (!isset($submittedLabelsBySlug[$slug]) || $submittedLabelsBySlug[$slug] === '') {
714 + return __('Existing poll options cannot be removed or left empty.', 'fluent-community');
715 + }
716 + }
717 +
718 + return null;
719 + }
720 +
570 721 public static function transformForEdit($feed)
571 722 {
572 723 $topicsConfig = Helper::getTopicsConfig();
573 724
@@ -591,8 +742,9 @@
591 742 ->where('is_active', 1)
592 743 ->get();
593 744 $mediaIds = [];
594 745 foreach ($documents as $document) {
746 + /** @var Media $document */
595 747 $mediaIds[] = $document->getPrivateFileMeta();
596 748 }
597 749 $feed->document_ids = $mediaIds;
598 750 $feed->load('space');
@@ -620,11 +772,11 @@
620 772 if ($type == 'oembed' || $type == 'iframe_html') {
621 773 $feed->media = $mediaPreview;
622 774 }
623 775
624 - // Only fetch the specific attached media, not all media (which would include inline images)
776 + // Only fetch the specific attached media, not all media (which would include inline images).
625 777 $mediaId = Arr::get($mediaPreview, 'media_id');
626 - if ($mediaId) {
778 + if ($mediaId && $type != 'oembed' && $type != 'iframe_html') {
627 779 $media = Media::where('id', $mediaId)
628 780 ->where('feed_id', $feed->id)
629 781 ->where('is_active', 1)
630 782 ->first();
@@ -643,12 +795,68 @@
643 795 $feed->meta = $meta;
644 796 }
645 797 }
646 798
799 + // Preserve multi-audio so the edit composer can load, edit/remove, and re-save them
800 + // (transformForEdit otherwise drops meta for audio-only posts).
801 + $audioMedias = Arr::get($meta, 'audio_medias', []);
802 + if ($audioMedias) {
803 + $editMeta = (isset($feed->meta) && is_array($feed->meta)) ? $feed->meta : [];
804 + $editMeta['audio_medias'] = $audioMedias;
805 + $feed->meta = $editMeta;
806 + }
807 +
647 808 $feed->load('space');
648 809 return $feed;
649 810 }
650 811
812 + /**
813 + * Whether the current request may attach a raw "HTML Code" (iframe_html) embed.
814 + *
815 + * Mirrors the frontend rule in _VideoEmbeder.vue, which exposes that editor tab only
816 + * when is_admin is true — i.e. community_moderator globally or within the target
817 + * space. Programmatic creation is judged on the supplied author's permission rather
818 + * than the HTTP session, so integrations work without a logged-in user. Defaults to
819 + * denying when no user can be established at all.
820 + *
821 + * @param array $requestData Raw request payload.
822 + * @param array $data Feed data being assembled.
823 + * @param \FluentCommunity\App\Models\Feed|null $existingFeed Set when editing.
824 + * @return bool
825 + */
826 + private static function canEmbedRawHtml($requestData, $data, $existingFeed = null)
827 + {
828 + // FeedsController::store()/update() already resolved this against the target space.
829 + $precomputed = Arr::get($requestData, 'is_admin');
830 + if ($precomputed !== null) {
831 + return (bool)$precomputed;
832 + }
833 +
834 + // Every other caller resolves it here, against the post's author where one has
835 + // been established server-side (createFeed() takes user_id from its caller), and
836 + // the current user otherwise. Read from $data and never $requestData: the author
837 + // is assigned by the controller, so a request cannot nominate whose permission
838 + // gets checked.
839 + $userId = (int)Arr::get($data, 'user_id');
840 + if (!$userId) {
841 + $userId = get_current_user_id();
842 + }
843 +
844 + $user = $userId ? User::find($userId) : null;
845 + if (!$user) {
846 + return false;
847 + }
848 +
849 + $space = null;
850 + if ($existingFeed) {
851 + $space = $existingFeed->space;
852 + } elseif ($spaceId = (Arr::get($data, 'space_id') ?: Arr::get($requestData, 'space_id'))) {
853 + $space = BaseSpace::find($spaceId);
854 + }
855 +
856 + return (bool)$user->hasPermissionOrInCurrentSpace('community_moderator', $space);
857 + }
858 +
651 859 public static function processFeedMetaData($data, $requestData, $existingFeed = null)
652 860 {
653 861 if (empty($data['meta'])) {
654 862 $data['meta'] = [];
@@ -716,9 +924,45 @@
716 924 Arr::get($requestData, 'media.type') == 'iframe_html'
717 925 )
718 926 ) {
719 927 if (Arr::get($requestData, 'media.type') == 'iframe_html') {
720 - $data['meta']['media_preview'] = array_filter(Arr::get($requestData, 'media', []));
928 + // The UI only offers the "HTML Code" embed to moderators
929 + // (_VideoEmbeder.vue passes has_iframe="is_admin"). That is a hint, not a
930 + // control, so the same rule is enforced here. Reaching this branch without
931 + // the permission means the field was posted straight to the REST API, so
932 + // the embed is dropped rather than stored.
933 + if (!self::canEmbedRawHtml($requestData, $data, $existingFeed)) {
934 + return [$data, $uplaodedDocs];
935 + }
936 +
937 + $mediaPreview = array_filter(Arr::get($requestData, 'media', []));
938 +
939 + // Moderators are trusted to embed, not to bypass sanitization: the markup
940 + // still goes through the same allowlist the oembed branch below uses.
941 + if (!empty($mediaPreview['html'])) {
942 + $mediaPreview['html'] = RemoteUrlParser::sanitizeOembedHtml($mediaPreview['html']);
943 +
944 + // Keep only if a usable <iframe> survived; else it renders as junk.
945 + if (stripos($mediaPreview['html'], '<iframe') === false) {
946 + unset($mediaPreview['html']);
947 + }
948 +
949 + $mediaPreview = array_filter($mediaPreview);
950 + }
951 +
952 + if (empty($mediaPreview['image']) && !empty($mediaPreview['html'])) {
953 + $thumb = RemoteUrlParser::extractIframeThumbnail($mediaPreview['html']);
954 + if ($thumb) {
955 + $mediaPreview['image'] = $thumb;
956 + }
957 + }
958 +
959 + // Nothing usable survived; skip storing a broken preview.
960 + if (empty($mediaPreview['html']) && empty($mediaPreview['image'])) {
961 + return [$data, $uplaodedDocs];
962 + }
963 +
964 + $data['meta']['media_preview'] = $mediaPreview;
721 965 return [$data, $uplaodedDocs];
722 966 }
723 967
724 968 $media = Arr::get($requestData, 'media');
@@ -881,11 +1125,27 @@
881 1125 $feedMeta['document_lists'] = $documentLists;
882 1126 $feed->meta = $feedMeta;
883 1127 }
884 1128
885 - $spaceSettings = Space::where('id', $feed->space_id)->value('settings');
1129 + $spaceSettings = $feed->space ? $feed->space->settings : [];
886 1130 $feed->default_comment_sort_by = Arr::get($spaceSettings, 'default_comment_sort_by', '');
887 1131
1132 + // Feed::withPublicRelations() eager-loads the space with its raw settings, and
1133 + // those settings carry links scoped to logged-in members or to specific
1134 + // memberships. BaseSpace::formatSpaceData() filters them for the space
1135 + // endpoints; nothing filtered them here, so every feed response handed all of
1136 + // a space's links - titles and URLs - to any caller, anonymous included.
1137 + if ($feed->space && Arr::get($spaceSettings, 'links')) {
1138 + $currentUser = Helper::getCurrentUser();
1139 +
1140 + $spaceSettings['links'] = Helper::filterAccessibleLinks(
1141 + Arr::get($spaceSettings, 'links', []),
1142 + $currentUser ? $currentUser : null
1143 + );
1144 +
1145 + $feed->space->settings = $spaceSettings;
1146 + }
1147 +
888 1148 self::setCurrentRelatedUserId($feed->user_id);
889 1149
890 1150 return apply_filters('fluent_community/rendering_feed_model', $feed, $config);
891 1151 }
@@ -984,9 +1244,9 @@
984 1244 $feedHtml = '';
985 1245
986 1246 if ($mediaImage) {
987 1247 $feedHtml .= '<div class="fcom_media" style="margin-top: 20px;">';
988 - $feedHtml .= '<a href="' . $postPermalink . '"><img src="' . $mediaImage . '" style="max-width: 100%; height: auto; display: block; margin: 0 auto 0px;" /></a>';
1248 + $feedHtml .= '<a href="' . $postPermalink . '"><img src="' . $mediaImage . '" alt="" style="max-width: 100%; height: auto; display: block; margin: 0 auto 0px;" /></a>';
989 1249 if ($mediaCount > 1) {
990 1250 /* translators: %d is the number of additional images not shown in the preview. */
991 1251 $feedHtml .= '<p style="text-align: center; font-size: 14px; color: #666; margin-top: 10px;">' . sprintf(_n('+%d more image', '+%d more images', $mediaCount - 1, 'fluent-community'), $mediaCount - 1) . '</p>';
992 1252 }