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 +243 -19 2.6.02.11.0 View file →
@@ -2,9 +2,9 @@
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 9 use FluentCommunity\App\Models\Reaction;
10 10 use FluentCommunity\App\Models\Term;
@@ -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
@@ -26,8 +27,25 @@
26 27 {
27 28 return array_values(array_unique(self::$currentRelatedUserIds));
28 29 }
29 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 +
30 48 public static function getSpaceSlugsByUserId($userId)
31 49 {
32 50 if (!$userId) {
33 51 $userId = get_current_user_id();
@@ -41,8 +59,71 @@
41 59
42 60 return $user->spaces()->pluck('slug')->toArray();
43 61 }
44 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 +
45 126 public static function getLastFeedId()
46 127 {
47 128 $lastItem = Feed::where('status', 'published')
48 129 ->byUserAccess(get_current_user_id())
@@ -106,8 +187,25 @@
106 187 'code' => array(),
107 188 'pre' => array(),
108 189 'blockquote' => array(),
109 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 + ),
110 208 ));
111 209
112 210 return self::maybeTransformDynamicCodes($html);
113 211 }
@@ -215,15 +313,27 @@
215 313 }
216 314
217 315 public static function findFirstUrl($html)
218 316 {
219 - // use regular expression to find the first URL in a href tag
220 - // do not take the url which contains /u/ in it
221 - $pattern = '/<a\s+(?:[^>]*?\s+)?href=([\'"])(?!.*\/u\/)(.*?)\1/';
222 - preg_match($pattern, $html, $matches);
317 + if (!preg_match_all('/<a\s+(?:[^>]*?\s+)?href=([\'"])(.*?)\1/i', $html, $matches)) {
318 + return '';
319 + }
223 320
224 - if (isset($matches[2])) {
225 - 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;
226 336 }
227 337
228 338 return '';
229 339 }
@@ -468,8 +578,13 @@
468 578 $feedData['meta']['mentioned_user_ids'] = Arr::get($mentions, 'user_ids', []);
469 579 }
470 580
471 581 $data = apply_filters('fluent_community/feed/new_feed_data', $feedData, $allData);
582 +
583 + if (is_wp_error($data)) {
584 + return $data;
585 + }
586 +
472 587 $feed = new Feed();
473 588 $feed->fill($data);
474 589 $feed->save();
475 590
@@ -496,9 +611,9 @@
496 611 }
497 612
498 613 public static function sanitizeAndValidateData($data)
499 614 {
500 - $message = CustomSanitizer::unslashMarkdown(trim(Arr::get($data, 'message')));
615 + $message = CustomSanitizer::unslashMarkdown(trim((string) Arr::get($data, 'message', '')));
501 616
502 617 // Decode HTML entities and strip all whitespace for validation
503 618 $messageForValidation = html_entity_decode($message, ENT_QUOTES | ENT_HTML5, 'UTF-8');
504 619 $messageForValidation = preg_replace('/\s+/u', '', $messageForValidation);
@@ -503,9 +618,12 @@
503 618 $messageForValidation = html_entity_decode($message, ENT_QUOTES | ENT_HTML5, 'UTF-8');
504 619 $messageForValidation = preg_replace('/\s+/u', '', $messageForValidation);
505 620
506 621 if (!$messageForValidation) {
507 - 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 + );
508 626 }
509 627
510 628 $processedData = [
511 629 'message' => $message,
@@ -545,10 +663,13 @@
545 663 }
546 664
547 665 $maxlen = apply_filters('fluent_community/max_post_length', 15000);
548 666 if (\strlen($message) > $maxlen) {
549 - /* translators: %s is the maximum allowed character count */
550 - 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 + );
551 672 }
552 673
553 674 $titlePref = Utility::postTitlePref();
554 675
@@ -554,13 +675,16 @@
554 675
555 676 if ($titlePref) {
556 677 $processedData['title'] = sanitize_text_field(Arr::get($data, 'title'));
557 678 if ($titlePref == 'required' && empty($processedData['title'])) {
558 - 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 + );
559 683 }
560 - // trim the title if it's too long to 150 char
561 - if (\strlen($processedData['title']) > 192) {
562 - $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');
563 687 }
564 688 }
565 689
566 690 return $processedData;
@@ -618,8 +742,9 @@
618 742 ->where('is_active', 1)
619 743 ->get();
620 744 $mediaIds = [];
621 745 foreach ($documents as $document) {
746 + /** @var Media $document */
622 747 $mediaIds[] = $document->getPrivateFileMeta();
623 748 }
624 749 $feed->document_ids = $mediaIds;
625 750 $feed->load('space');
@@ -647,11 +772,11 @@
647 772 if ($type == 'oembed' || $type == 'iframe_html') {
648 773 $feed->media = $mediaPreview;
649 774 }
650 775
651 - // 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).
652 777 $mediaId = Arr::get($mediaPreview, 'media_id');
653 - if ($mediaId) {
778 + if ($mediaId && $type != 'oembed' && $type != 'iframe_html') {
654 779 $media = Media::where('id', $mediaId)
655 780 ->where('feed_id', $feed->id)
656 781 ->where('is_active', 1)
657 782 ->first();
@@ -670,12 +795,68 @@
670 795 $feed->meta = $meta;
671 796 }
672 797 }
673 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 +
674 808 $feed->load('space');
675 809 return $feed;
676 810 }
677 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 +
678 859 public static function processFeedMetaData($data, $requestData, $existingFeed = null)
679 860 {
680 861 if (empty($data['meta'])) {
681 862 $data['meta'] = [];
@@ -743,10 +924,32 @@
743 924 Arr::get($requestData, 'media.type') == 'iframe_html'
744 925 )
745 926 ) {
746 927 if (Arr::get($requestData, 'media.type') == 'iframe_html') {
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 +
747 937 $mediaPreview = array_filter(Arr::get($requestData, 'media', []));
748 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 +
749 952 if (empty($mediaPreview['image']) && !empty($mediaPreview['html'])) {
750 953 $thumb = RemoteUrlParser::extractIframeThumbnail($mediaPreview['html']);
751 954 if ($thumb) {
752 955 $mediaPreview['image'] = $thumb;
@@ -752,8 +955,13 @@
752 955 $mediaPreview['image'] = $thumb;
753 956 }
754 957 }
755 958
959 + // Nothing usable survived; skip storing a broken preview.
960 + if (empty($mediaPreview['html']) && empty($mediaPreview['image'])) {
961 + return [$data, $uplaodedDocs];
962 + }
963 +
756 964 $data['meta']['media_preview'] = $mediaPreview;
757 965 return [$data, $uplaodedDocs];
758 966 }
759 967
@@ -917,11 +1125,27 @@
917 1125 $feedMeta['document_lists'] = $documentLists;
918 1126 $feed->meta = $feedMeta;
919 1127 }
920 1128
921 - $spaceSettings = Space::where('id', $feed->space_id)->value('settings');
1129 + $spaceSettings = $feed->space ? $feed->space->settings : [];
922 1130 $feed->default_comment_sort_by = Arr::get($spaceSettings, 'default_comment_sort_by', '');
923 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 +
924 1148 self::setCurrentRelatedUserId($feed->user_id);
925 1149
926 1150 return apply_filters('fluent_community/rendering_feed_model', $feed, $config);
927 1151 }
@@ -1020,9 +1244,9 @@
1020 1244 $feedHtml = '';
1021 1245
1022 1246 if ($mediaImage) {
1023 1247 $feedHtml .= '<div class="fcom_media" style="margin-top: 20px;">';
1024 - $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>';
1025 1249 if ($mediaCount > 1) {
1026 1250 /* translators: %d is the number of additional images not shown in the preview. */
1027 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>';
1028 1252 }