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 +117 -9 2.7.72.11.0 View file →
@@ -9,8 +9,9 @@
9 9 use FluentCommunity\App\Models\Reaction;
10 10 use FluentCommunity\App\Models\Term;
11 11 use FluentCommunity\App\Models\User;
12 12 use FluentCommunity\App\Models\XProfile;
13 +use FluentCommunity\Framework\Foundation\Exceptions\UnprocessableEntityHttpException;
13 14 use FluentCommunity\Framework\Support\Arr;
14 15 use FluentCommunity\Framework\Validator\Validator;
15 16
16 17 class FeedsHelper
@@ -58,8 +59,71 @@
58 59
59 60 return $user->spaces()->pluck('slug')->toArray();
60 61 }
61 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 +
62 126 public static function getLastFeedId()
63 127 {
64 128 $lastItem = Feed::where('status', 'published')
65 129 ->byUserAccess(get_current_user_id())
@@ -256,9 +320,17 @@
256 320
257 321 $profileUrlPrefix = Helper::baseUrl('u/');
258 322
259 323 foreach ($matches[2] as $href) {
260 - if (strpos($href, $profileUrlPrefix) === 0) {
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) {
261 333 continue;
262 334 }
263 335 return $href;
264 336 }
@@ -539,9 +611,9 @@
539 611 }
540 612
541 613 public static function sanitizeAndValidateData($data)
542 614 {
543 - $message = CustomSanitizer::unslashMarkdown(trim(Arr::get($data, 'message')));
615 + $message = CustomSanitizer::unslashMarkdown(trim((string) Arr::get($data, 'message', '')));
544 616
545 617 // Decode HTML entities and strip all whitespace for validation
546 618 $messageForValidation = html_entity_decode($message, ENT_QUOTES | ENT_HTML5, 'UTF-8');
547 619 $messageForValidation = preg_replace('/\s+/u', '', $messageForValidation);
@@ -546,9 +618,12 @@
546 618 $messageForValidation = html_entity_decode($message, ENT_QUOTES | ENT_HTML5, 'UTF-8');
547 619 $messageForValidation = preg_replace('/\s+/u', '', $messageForValidation);
548 620
549 621 if (!$messageForValidation) {
550 - 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 + );
551 626 }
552 627
553 628 $processedData = [
554 629 'message' => $message,
@@ -588,10 +663,13 @@
588 663 }
589 664
590 665 $maxlen = apply_filters('fluent_community/max_post_length', 15000);
591 666 if (\strlen($message) > $maxlen) {
592 - /* translators: %s is the maximum allowed character count */
593 - 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 + );
594 672 }
595 673
596 674 $titlePref = Utility::postTitlePref();
597 675
@@ -597,9 +675,12 @@
597 675
598 676 if ($titlePref) {
599 677 $processedData['title'] = sanitize_text_field(Arr::get($data, 'title'));
600 678 if ($titlePref == 'required' && empty($processedData['title'])) {
601 - 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 + );
602 683 }
603 684 // trim the title if it's too long to 192 chars (multibyte-safe; column is VARCHAR(192) characters)
604 685 if (mb_strlen($processedData['title']) > 192) {
605 686 $processedData['title'] = mb_substr($processedData['title'], 0, 192, 'UTF-8');
@@ -691,11 +772,11 @@
691 772 if ($type == 'oembed' || $type == 'iframe_html') {
692 773 $feed->media = $mediaPreview;
693 774 }
694 775
695 - // 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).
696 777 $mediaId = Arr::get($mediaPreview, 'media_id');
697 - if ($mediaId) {
778 + if ($mediaId && $type != 'oembed' && $type != 'iframe_html') {
698 779 $media = Media::where('id', $mediaId)
699 780 ->where('feed_id', $feed->id)
700 781 ->where('is_active', 1)
701 782 ->first();
@@ -858,8 +939,14 @@
858 939 // Moderators are trusted to embed, not to bypass sanitization: the markup
859 940 // still goes through the same allowlist the oembed branch below uses.
860 941 if (!empty($mediaPreview['html'])) {
861 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 +
862 949 $mediaPreview = array_filter($mediaPreview);
863 950 }
864 951
865 952 if (empty($mediaPreview['image']) && !empty($mediaPreview['html'])) {
@@ -868,8 +955,13 @@
868 955 $mediaPreview['image'] = $thumb;
869 956 }
870 957 }
871 958
959 + // Nothing usable survived; skip storing a broken preview.
960 + if (empty($mediaPreview['html']) && empty($mediaPreview['image'])) {
961 + return [$data, $uplaodedDocs];
962 + }
963 +
872 964 $data['meta']['media_preview'] = $mediaPreview;
873 965 return [$data, $uplaodedDocs];
874 966 }
875 967
@@ -1036,8 +1128,24 @@
1036 1128
1037 1129 $spaceSettings = $feed->space ? $feed->space->settings : [];
1038 1130 $feed->default_comment_sort_by = Arr::get($spaceSettings, 'default_comment_sort_by', '');
1039 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 +
1040 1148 self::setCurrentRelatedUserId($feed->user_id);
1041 1149
1042 1150 return apply_filters('fluent_community/rendering_feed_model', $feed, $config);
1043 1151 }
@@ -1136,9 +1244,9 @@
1136 1244 $feedHtml = '';
1137 1245
1138 1246 if ($mediaImage) {
1139 1247 $feedHtml .= '<div class="fcom_media" style="margin-top: 20px;">';
1140 - $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>';
1141 1249 if ($mediaCount > 1) {
1142 1250 /* translators: %d is the number of additional images not shown in the preview. */
1143 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>';
1144 1252 }