PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.7.7
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.7.7
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
fluent-community / app / Services / FeedsHelper.php

FeedsHelper.php in FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses 2.7.7, at app/Services/FeedsHelper.php

1,220 lines 43.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCommunity\App\Services;
4
5 use FluentCommunity\App\Functions\Utility;
6 use FluentCommunity\App\Models\BaseSpace;
7 use FluentCommunity\App\Models\Feed;
8 use FluentCommunity\App\Models\Media;
9 use FluentCommunity\App\Models\Reaction;
10 use FluentCommunity\App\Models\Term;
11 use FluentCommunity\App\Models\User;
12 use FluentCommunity\App\Models\XProfile;
13 use FluentCommunity\Framework\Support\Arr;
14 use FluentCommunity\Framework\Validator\Validator;
15
16 class FeedsHelper
17 {
18 static protected $currentRelatedUserIds = [];
19
20 public static function setCurrentRelatedUserId($userId)
21 {
22 self::$currentRelatedUserIds[] = $userId;
23 }
24
25 public static function getCurrentRelatedUserIds()
26 {
27 return array_values(array_unique(self::$currentRelatedUserIds));
28 }
29
30 /**
31 * Resolve who should receive the "post author" notification for a feed.
32 * Course lessons notify the COURSE creator (whoever created the course),
33 * not the user who uploaded the individual lesson.
34 */
35 public static function getNotificationAuthorId($feed)
36 {
37 if ($feed->type === 'course_lesson' && $feed->space_id) {
38 $course = BaseSpace::withoutGlobalScopes()->find($feed->space_id);
39 if ($course && $course->created_by) {
40 return (int) $course->created_by;
41 }
42 }
43
44 return (int) $feed->user_id;
45 }
46
47 public static function getSpaceSlugsByUserId($userId)
48 {
49 if (!$userId) {
50 $userId = get_current_user_id();
51 }
52
53 if (!$userId) {
54 return [];
55 }
56
57 $user = User::find($userId);
58
59 return $user->spaces()->pluck('slug')->toArray();
60 }
61
62 public static function getLastFeedId()
63 {
64 $lastItem = Feed::where('status', 'published')
65 ->byUserAccess(get_current_user_id())
66 ->orderBy('id', 'DESC')
67 ->first();
68
69 if ($lastItem) {
70 return $lastItem->id;
71 }
72
73 return 1;
74 }
75
76 public static function mdToHtml($text, $options = [])
77 {
78 if (!$text) {
79 return '';
80 }
81
82 $text = str_replace('&#x20;', '', $text); // hide markdown empty content
83
84 $html = (new \FluentCommunity\App\Services\Parsedown([
85 ]))
86 ->setBreaksEnabled(true)
87 ->setUrlsLinked(false)
88 // ->setSafeMode(true)
89 ->text($text);
90
91 if (!Arr::get($options, 'disable_link_process')) {
92 // add nofollow to all links. But check if nofollow is already there
93 $html = self::addNoFollowToLinks($html);
94 }
95
96 $html = wp_kses($html, array(
97 'p' => array(),
98 'br' => array(),
99 'strong' => array(),
100 'em' => array(),
101 'hr' => array(),
102 'h1' => array(),
103 'h2' => array(),
104 'h3' => array(),
105 'h4' => array(),
106 'h5' => array(),
107 'h6' => array(),
108 'ul' => array(),
109 'b' => array(),
110 'ol' => array(),
111 'li' => array(),
112 'span' => array(),
113 'a' => array(
114 'href' => true,
115 'title' => true,
116 'rel' => true,
117 'target' => true,
118 ),
119 'img' => array(
120 'src' => true,
121 'alt' => true,
122 ),
123 'code' => array(),
124 'pre' => array(),
125 'blockquote' => array(),
126 'del' => array(),
127 'table' => array(),
128 'thead' => array(),
129 'tbody' => array(),
130 'tfoot' => array(),
131 'tr' => array(),
132 'th' => array(
133 'align' => true,
134 'style' => true,
135 'colspan' => true,
136 'rowspan' => true,
137 ),
138 'td' => array(
139 'align' => true,
140 'style' => true,
141 'colspan' => true,
142 'rowspan' => true,
143 ),
144 ));
145
146 return self::maybeTransformDynamicCodes($html);
147 }
148
149 public static function maybeTransformDynamicCodes($html)
150 {
151 // check if there has {{
152 if (strpos($html, '{{') === false) {
153 return $html;
154 }
155
156 return preg_replace_callback(
157 '/{{utc:(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})}}/',
158 function ($match) {
159 // Extract the datetime string (e.g., 2025-06-01 15:06:59)
160 $datetimeStr = $match[1];
161
162 try {
163 // Create a DateTime object from the UTC string
164 $date = new \DateTime($datetimeStr, new \DateTimeZone('UTC'));
165 // Get the Unix timestamp for the data-timestamp attribute
166 $timestamp = $date->getTimestamp();
167 // Format the display string
168 $displayFormat = $date->format('d F Y, H:i') . ' (UTC)';
169
170 // Return the formatted HTML
171 return '<span class="fcom_dynamic_prop" data-type="timestamp" data-timestamp="' . $timestamp . '">' . $displayFormat . '</span>';
172 } catch (\Exception $e) {
173 // Return original match if parsing fails
174 return $match[0];
175 }
176 },
177 $html
178 );
179 }
180
181 public static function addNoFollowToLinks($html)
182 {
183 if (!$html) {
184 return '';
185 }
186
187 $current_domain = wp_parse_url(home_url(), PHP_URL_HOST);
188
189 // Regular expression to match <a> tags
190 $pattern = '/<a\s[^>]*href=("|\')(https?:\/\/(?!' . preg_quote($current_domain, '/') . ').*?)("|\')\s?([^>]*)>/i';
191
192 // Callback function to modify each matched <a> tag
193 $callback = function ($matches) {
194 $url = $matches[2];
195 $attr = $matches[4];
196
197 // Remove existing rel attribute if present
198 $attr = preg_replace('/\srel=("|\').*?("|\')/i', '', $attr);
199
200 // Add nofollow
201 return '<a href="' . $url . '" rel="nofollow" ' . trim($attr) . '>';
202 };
203
204 // Perform the replacement
205 return preg_replace_callback($pattern, $callback, $html);
206 }
207
208 public static function addNewTabToLinks($html)
209 {
210 if (empty($html) || !is_string($html)) {
211 return '';
212 }
213
214 // return is there has no href
215 if (strpos($html, 'href=') === false) {
216 return $html;
217 }
218
219 // More comprehensive regex to capture existing attributes
220 $pattern = '/<a\s+([^>]*)>/i';
221
222 // Callback function to modify each matched <a> tag
223 $callback = function ($matches) {
224 $full_tag = $matches[0];
225 $attributes = $matches[1];
226
227 // Extract href
228 preg_match('/href=("|\')([^"\']+)("|\')/', $full_tag, $href_matches);
229 if (empty($href_matches)) {
230 return $full_tag;
231 }
232 $url = $href_matches[2];
233
234 // Check if it's an external URL and not an image
235 if (preg_match('/^https?:\/\//i', $url) && !preg_match('/\.(jpg|jpeg|png|gif|svg)$/i', $url)) {
236 // Check if target already exists
237 if (!preg_match('/\btarget=/i', $full_tag)) {
238 // Preserve existing attributes, add target="_blank"
239 return '<a ' . $attributes . ' target="_blank" rel="noopener noreferrer">';
240 }
241 }
242
243 // Return original tag if no modification needed
244 return $full_tag;
245 };
246
247 // Perform the replacement
248 return preg_replace_callback($pattern, $callback, $html);
249 }
250
251 public static function findFirstUrl($html)
252 {
253 if (!preg_match_all('/<a\s+(?:[^>]*?\s+)?href=([\'"])(.*?)\1/i', $html, $matches)) {
254 return '';
255 }
256
257 $profileUrlPrefix = Helper::baseUrl('u/');
258
259 foreach ($matches[2] as $href) {
260 if (strpos($href, $profileUrlPrefix) === 0) {
261 continue;
262 }
263 return $href;
264 }
265
266 return '';
267 }
268
269 public static function extractHashTags($text, $limit = 5)
270 {
271 // Extract hashtag including - and _
272 preg_match_all('/#([a-zA-Z0-9_-]+)/', $text, $matches);
273
274 $tags = array_unique($matches[1]);
275
276 if (!$tags) {
277 return [];
278 }
279
280 $tags = array_slice($tags, 0, $limit);
281
282 $lowerCaseTags = array_map('strtolower', $tags);
283
284 $terms = Term::whereIn('slug', $lowerCaseTags)
285 ->where('taxonomy_name', 'hashtag')
286 ->get();
287
288 $termIds = [];
289
290 foreach ($terms as $term) {
291 $termIds[$term->slug] = $term->id;
292 }
293
294 if (count($termIds) == count($tags)) {
295 return array_values($termIds);
296 }
297
298 $excepts = array_diff($tags, array_keys($termIds));
299
300 foreach ($excepts as $except) {
301 $term = Term::create([
302 'taxonomy_name' => 'hashtag',
303 'slug' => strtolower($except),
304 'title' => $except
305 ]);
306
307 $termIds[$term->slug] = $term->id;
308 }
309
310 return array_values($termIds);
311 }
312
313 public static function getMentions($text, $spaceId = null, $withUsers = false)
314 {
315 // the mention may have . or _ or - in the username
316 preg_match_all('/@([a-zA-Z0-9_.-]+)/', $text, $matches);
317 $mentions = array_unique($matches[1]);
318
319 if (!$mentions) {
320 return null;
321 }
322
323 if ($spaceId) {
324 $xProfiles = XProfile::whereIn('username', $mentions)
325 ->whereHas('spaces', function ($query) use ($spaceId) {
326 $query->withoutGlobalScopes()->where('space_id', $spaceId);
327 })
328 ->get();
329 } else {
330 $xProfiles = XProfile::whereIn('username', $mentions)
331 ->get();
332 }
333
334 if ($xProfiles->isEmpty()) {
335 return null;
336 }
337
338 $userMentions = [];
339
340 $userIds = [];
341
342 foreach ($xProfiles as $xProfile) {
343 $userIds[] = $xProfile->user_id;
344 $html = '<a data-user_name="' . $xProfile->username . '" class="fcom_mention fcom_route" href="' . Helper::baseUrl('u/' . $xProfile->username . '/') . '">' . $xProfile->display_name . '</a>';
345 $userMentions['@' . $xProfile->username] = $html;
346 }
347
348 $data = [
349 'user_ids' => $userIds,
350 'text' => strtr($text, $userMentions)
351 ];
352
353 if ($withUsers) {
354 $data['users'] = User::whereIn('ID', $userIds)->get();
355 }
356
357 return $data;
358 }
359
360 public static function getLikedIdsByUserFeedId($feedId, $userId)
361 {
362 return Reaction::select('object_id')
363 ->where('object_type', 'comment')
364 ->where('parent_id', $feedId)
365 ->where('user_id', $userId)
366 ->get()
367 ->pluck('object_id')
368 ->toArray();
369 }
370
371 public static function castSurveyVote($newVoteIndexes, Feed $feed, $userId)
372 {
373 $surveyConfig = Arr::get($feed->meta, 'survey_config', []);
374
375 $slugs = array_map(function ($item) {
376 return $item['slug'];
377 }, $surveyConfig['options']);
378
379 $newVoteIndexes = array_filter(array_intersect($slugs, $newVoteIndexes));
380
381 $previousVotes = Reaction::where('type', 'survey_vote')
382 ->where('user_id', $userId)
383 ->where('object_id', $feed->id)
384 ->get();
385
386 $removedIndexes = [];
387 $alreadyIndexes = [];
388
389 foreach ($previousVotes as $previousVote) {
390 if (!in_array($previousVote->object_type, $newVoteIndexes)) {
391 // This vote need to be deleted
392 $removedIndexes[] = $previousVote->object_type;
393 $previousVote->delete();
394 } else {
395 $alreadyIndexes[] = $previousVote->object_type;
396 }
397 }
398
399 $newSyncIndexes = array_diff($newVoteIndexes, $alreadyIndexes);
400
401 foreach ($newSyncIndexes as $newSyncIndex) {
402 Reaction::create([
403 'user_id' => $userId,
404 'object_id' => $feed->id,
405 'type' => 'survey_vote',
406 'object_type' => $newSyncIndex
407 ]);
408 }
409
410 if (!empty($newSyncIndexes)) {
411 do_action('fluent_community/feed/cast_survey_vote', $newSyncIndexes, $feed, $userId);
412 }
413
414 foreach ($surveyConfig['options'] as $index => $option) {
415 $slug = $option['slug'];
416
417 if (in_array($slug, $removedIndexes)) {
418 $newCount = (int)Arr::get($option, 'vote_counts', 0) - 1;
419 $option['vote_counts'] = $newCount > 0 ? $newCount : 0;
420 } else if (in_array($slug, $newSyncIndexes)) {
421 $newCount = (int)Arr::get($option, 'vote_counts', 0) + 1;
422 $option['vote_counts'] = $newCount > 0 ? $newCount : 0;
423 }
424
425 $surveyConfig['options'][$index] = $option;
426 }
427
428 $surveyConfig = apply_filters('fluent_community/feed/updated_survey_config', $surveyConfig, $feed, $userId);
429
430 $meta = $feed->meta;
431 $meta['survey_config'] = $surveyConfig;
432 $feed->meta = $meta;
433 $feed->save();
434
435 Utility::forgetCache('survey_cast_' . $feed->id . '_' . $userId);
436
437 return $feed;
438 }
439
440 /**
441 * Create a new feed programmatically
442 * @param array $allData
443 * @return \FluentCommunity\App\Models\Feed|\WP_Error
444 **/
445 public static function createFeed($allData)
446 {
447 if (!is_array($allData)) {
448 return new \WP_Error('invalid_data', __('Invalid data. The data need to be array', 'fluent-community'), ['status' => 400]);
449 }
450
451 $acceptedKeys = ['message', 'title', 'user_id', 'space_id', 'type'];
452 $feedData = Arr::only($allData, $acceptedKeys);
453
454 // Let's validate the data
455 $validation = Validator::make($feedData, [
456 'message' => 'required',
457 'title' => 'nullable|string',
458 'user_id' => 'required|integer|exists:users,ID',
459 'space_id' => 'nullable|integer|exists:fcom_spaces,id'
460 ]);
461
462 if ($validation->fails()) {
463 return new \WP_Error('validation_failed', __('Validation failed', 'fluent-community'), $validation->errors());
464 }
465
466 $sanitizedData = self::sanitizeAndValidateData($feedData);
467
468 $feedData = wp_parse_args($sanitizedData, $feedData);
469
470 $user = User::find($feedData['user_id']);
471
472 if (!$user) {
473 return new \WP_Error('user_not_found', __('User not found', 'fluent-community'), ['status' => 400]);
474 }
475 $user->syncXProfile();
476 if ($user->xprofile->status != 'active') {
477 return new \WP_Error('user_inactive', __('User status is not active', 'fluent-community'), $validation->errors());
478 }
479
480 $markdown = $feedData['message'];
481 $mentions = null;
482
483 // Extra Validaton for space_id
484 if (!empty($feedData['space_id'])) {
485 if (!Helper::isUserInSpace($feedData['user_id'], $feedData['space_id'])) {
486 return new \WP_Error('invalid_space', __('User is not in the space', 'fluent-community'), ['status' => 400]);
487 }
488 $mentions = FeedsHelper::getMentions($markdown, Arr::get($feedData, 'space_id'), true);
489 if ($mentions) {
490 $markdown = $mentions['text'];
491 }
492 } else if (!Helper::hasGlobalPost()) {
493 return new \WP_Error('global_post_disabled', 'User is not allowed to post in global', ['status' => 400]);
494 }
495
496 $feedData['message_rendered'] = wp_kses_post(self::mdToHtml($markdown));
497 $feedData['status'] = 'published';
498
499 if (Arr::get($allData, 'meta.media_preview.provider') == 'inline') {
500 $allData['meta']['media_preview']['provider'] = 'giphy';
501 }
502
503 [$feedData, $mediaItems] = self::processFeedMetaData($feedData, $allData);
504
505 if ($mentions) {
506 $feedData['meta']['mentioned_user_ids'] = Arr::get($mentions, 'user_ids', []);
507 }
508
509 $data = apply_filters('fluent_community/feed/new_feed_data', $feedData, $allData);
510
511 if (is_wp_error($data)) {
512 return $data;
513 }
514
515 $feed = new Feed();
516 $feed->fill($data);
517 $feed->save();
518
519 if ($mentions) {
520 do_action('fluent_community/feed_mentioned', $feed, $mentions['users']);
521 }
522
523 if ($mediaItems) {
524 foreach ($mediaItems as $media) {
525 $media->feed_id = $feed->id;
526 $media->is_active = 1;
527 $media->object_source = 'feed';
528 $media->save();
529 }
530 }
531
532 do_action('fluent_community/feed/created', $feed);
533
534 if ($feed->space_id) {
535 do_action('fluent_community/space_feed/created', $feed);
536 }
537
538 return $feed;
539 }
540
541 public static function sanitizeAndValidateData($data)
542 {
543 $message = CustomSanitizer::unslashMarkdown(trim(Arr::get($data, 'message')));
544
545 // Decode HTML entities and strip all whitespace for validation
546 $messageForValidation = html_entity_decode($message, ENT_QUOTES | ENT_HTML5, 'UTF-8');
547 $messageForValidation = preg_replace('/\s+/u', '', $messageForValidation);
548
549 if (!$messageForValidation) {
550 throw new \Exception(esc_html__('Message is required', 'fluent-community'));
551 }
552
553 $processedData = [
554 'message' => $message,
555 'type' => 'text'
556 ];
557
558 $survey = Arr::get($data, 'survey', []);
559
560 if ($survey) {
561 $options = Arr::get($survey, 'options', []);
562 $formattedOptions = [];
563 foreach ($options as $index => $option) {
564 if (empty($option['label'])) {
565 continue;
566 }
567
568 $formattedOptions[] = [
569 'label' => sanitize_text_field($option['label']),
570 'slug' => Arr::get($option, 'slug') ?: 'opt_' . ($index + 1)
571 ];
572 }
573
574 $endDate = Arr::get($survey, 'end_date', '');
575 if ($endDate) {
576 $endDate = gmdate('Y-m-d H:i:s', strtotime($endDate));
577 } else {
578 $endDate = '';
579 }
580
581 if ($formattedOptions) {
582 $processedData['survey'] = [
583 'type' => Arr::get($survey, 'type') == 'single_choice' ? 'single_choice' : 'multi_choice',
584 'options' => $formattedOptions,
585 'end_date' => $endDate
586 ];
587 }
588 }
589
590 $maxlen = apply_filters('fluent_community/max_post_length', 15000);
591 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))));
594 }
595
596 $titlePref = Utility::postTitlePref();
597
598 if ($titlePref) {
599 $processedData['title'] = sanitize_text_field(Arr::get($data, 'title'));
600 if ($titlePref == 'required' && empty($processedData['title'])) {
601 throw new \Exception(esc_html__('Title is required. Please provide a title', 'fluent-community'));
602 }
603 // trim the title if it's too long to 192 chars (multibyte-safe; column is VARCHAR(192) characters)
604 if (mb_strlen($processedData['title']) > 192) {
605 $processedData['title'] = mb_substr($processedData['title'], 0, 192, 'UTF-8');
606 }
607 }
608
609 return $processedData;
610 }
611
612 public static function getSurveyOptionsUpdateError($existingSurveyOptions, $submittedSurvey)
613 {
614 if (empty($existingSurveyOptions) || empty($submittedSurvey)) {
615 return null;
616 }
617
618 $submittedLabelsBySlug = [];
619 foreach (Arr::get($submittedSurvey, 'options', []) as $option) {
620 $slug = Arr::get($option, 'slug', '');
621 if ($slug !== '') {
622 $submittedLabelsBySlug[$slug] = trim((string)Arr::get($option, 'label', ''));
623 }
624 }
625
626 foreach ($existingSurveyOptions as $existingOption) {
627 $slug = Arr::get($existingOption, 'slug', '');
628 if ($slug === '') {
629 continue;
630 }
631
632 if (!isset($submittedLabelsBySlug[$slug]) || $submittedLabelsBySlug[$slug] === '') {
633 return __('Existing poll options cannot be removed or left empty.', 'fluent-community');
634 }
635 }
636
637 return null;
638 }
639
640 public static function transformForEdit($feed)
641 {
642 $topicsConfig = Helper::getTopicsConfig();
643
644 $terms = $feed->terms;
645 $feed->topic_ids = $terms->where('taxonomy_name', 'post_topic')->pluck('id')->toArray();
646 if ($topicsConfig['max_topics_per_post'] == 1) {
647 if ($feed->topic_ids) {
648 $feed->topic_ids = Arr::first($feed->topic_ids);
649 } else {
650 $feed->topic_ids = '';
651 }
652 }
653
654 if (Arr::get($feed->meta, 'send_announcement_email') == 'yes') {
655 $feed->send_announcement_email = 'yes';
656 }
657
658 if ($feed->content_type == 'document') {
659 $documents = Media::where('object_source', 'space_document')
660 ->where('feed_id', $feed->id)
661 ->where('is_active', 1)
662 ->get();
663 $mediaIds = [];
664 foreach ($documents as $document) {
665 /** @var Media $document */
666 $mediaIds[] = $document->getPrivateFileMeta();
667 }
668 $feed->document_ids = $mediaIds;
669 $feed->load('space');
670 return $feed;
671 }
672
673 $surveyConfig = Arr::get($feed->meta, 'survey_config', []);
674
675 if ($surveyConfig) {
676 $feed->survey = [
677 'type' => Arr::get($surveyConfig, 'type'),
678 'options' => Arr::get($surveyConfig, 'options', []),
679 'end_date' => Arr::get($surveyConfig, 'end_date', '')
680 ];
681 }
682
683 $mediaImages = Arr::get($feed->meta, 'media_items', []);
684 $meta = $feed->meta;
685 unset($feed->meta);
686
687 if ($mediaImages) {
688 $feed->media_images = $mediaImages;
689 } else if ($mediaPreview = Arr::get($meta, 'media_preview')) {
690 $type = Arr::get($mediaPreview, 'type');
691 if ($type == 'oembed' || $type == 'iframe_html') {
692 $feed->media = $mediaPreview;
693 }
694
695 // Only fetch the specific attached media, not all media (which would include inline images)
696 $mediaId = Arr::get($mediaPreview, 'media_id');
697 if ($mediaId) {
698 $media = Media::where('id', $mediaId)
699 ->where('feed_id', $feed->id)
700 ->where('is_active', 1)
701 ->first();
702
703 if ($media) {
704 $feed->media_images = [[
705 'url' => $media->public_url,
706 'type' => 'image',
707 'media_id' => $media->id,
708 'width' => Arr::get($media->settings, 'width'),
709 'height' => Arr::get($media->settings, 'height'),
710 'provider' => Arr::get($media->settings, 'provider', 'uploader')
711 ]];
712 }
713 } else if ($type != 'meta_data') {
714 $feed->meta = $meta;
715 }
716 }
717
718 // Preserve multi-audio so the edit composer can load, edit/remove, and re-save them
719 // (transformForEdit otherwise drops meta for audio-only posts).
720 $audioMedias = Arr::get($meta, 'audio_medias', []);
721 if ($audioMedias) {
722 $editMeta = (isset($feed->meta) && is_array($feed->meta)) ? $feed->meta : [];
723 $editMeta['audio_medias'] = $audioMedias;
724 $feed->meta = $editMeta;
725 }
726
727 $feed->load('space');
728 return $feed;
729 }
730
731 /**
732 * Whether the current request may attach a raw "HTML Code" (iframe_html) embed.
733 *
734 * Mirrors the frontend rule in _VideoEmbeder.vue, which exposes that editor tab only
735 * when is_admin is true — i.e. community_moderator globally or within the target
736 * space. Programmatic creation is judged on the supplied author's permission rather
737 * than the HTTP session, so integrations work without a logged-in user. Defaults to
738 * denying when no user can be established at all.
739 *
740 * @param array $requestData Raw request payload.
741 * @param array $data Feed data being assembled.
742 * @param \FluentCommunity\App\Models\Feed|null $existingFeed Set when editing.
743 * @return bool
744 */
745 private static function canEmbedRawHtml($requestData, $data, $existingFeed = null)
746 {
747 // FeedsController::store()/update() already resolved this against the target space.
748 $precomputed = Arr::get($requestData, 'is_admin');
749 if ($precomputed !== null) {
750 return (bool)$precomputed;
751 }
752
753 // Every other caller resolves it here, against the post's author where one has
754 // been established server-side (createFeed() takes user_id from its caller), and
755 // the current user otherwise. Read from $data and never $requestData: the author
756 // is assigned by the controller, so a request cannot nominate whose permission
757 // gets checked.
758 $userId = (int)Arr::get($data, 'user_id');
759 if (!$userId) {
760 $userId = get_current_user_id();
761 }
762
763 $user = $userId ? User::find($userId) : null;
764 if (!$user) {
765 return false;
766 }
767
768 $space = null;
769 if ($existingFeed) {
770 $space = $existingFeed->space;
771 } elseif ($spaceId = (Arr::get($data, 'space_id') ?: Arr::get($requestData, 'space_id'))) {
772 $space = BaseSpace::find($spaceId);
773 }
774
775 return (bool)$user->hasPermissionOrInCurrentSpace('community_moderator', $space);
776 }
777
778 public static function processFeedMetaData($data, $requestData, $existingFeed = null)
779 {
780 if (empty($data['meta'])) {
781 $data['meta'] = [];
782 }
783
784 $uplaodedDocs = [];
785 // Handle Survey
786 if (!empty($data['survey'])) {
787 $surveyConfig = $data['survey'];
788 if ($existingFeed) {
789 $surveyConfig = Arr::get($existingFeed->meta, 'survey_config', []);
790 if ($surveyConfig) {
791 $oldOptions = Arr::get($surveyConfig, 'options', []);
792 $newOptions = Arr::get($data['survey'], 'options', []);
793 $oldKeyedOptions = [];
794 foreach ($oldOptions as $option) {
795 $oldKeyedOptions[$option['slug']] = $option;
796 }
797 foreach ($newOptions as $index => $option) {
798 $slug = Arr::get($option, 'slug', '');
799 if (isset($oldKeyedOptions[$slug])) {
800 $newOptions[$index]['vote_counts'] = Arr::get($oldKeyedOptions[$slug], 'vote_counts', 0);
801 }
802 }
803 $surveyConfig['options'] = $newOptions;
804 } else {
805 $surveyConfig = $data['survey'];
806 }
807 }
808
809 if ($endDate = Arr::get($data['survey'], 'end_date', '')) {
810 $surveyConfig['end_date'] = gmdate('Y-m-d H:i:s', strtotime($endDate));
811 } else {
812 $surveyConfig['end_date'] = '';
813 }
814
815 $data['meta']['survey_config'] = $surveyConfig;
816 $data['content_type'] = 'survey';
817 unset($data['survey']);
818 }
819
820 // Handle Giphy
821 if (Arr::get($requestData, 'meta.media_preview.provider') == 'giphy') {
822 $url = Arr::get($requestData, 'meta.media_preview.image');
823 if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) {
824 return [$data, $uplaodedDocs];
825 }
826
827 $data['meta']['media_preview'] = array_filter([
828 'image' => sanitize_url($url),
829 'type' => sanitize_text_field(Arr::get($requestData, 'meta.media_preview.type', 'image')),
830 'provider' => 'giphy',
831 'height' => (int)Arr::get($requestData, 'meta.media_preview.height', 0),
832 'width' => (int)Arr::get($requestData, 'meta.media_preview.width', 0),
833 ]);
834
835 return [$data, $uplaodedDocs];
836 }
837
838 // Handling Video Embed
839 if (
840 Arr::get($requestData, 'media') &&
841 (
842 (Arr::get($requestData, 'media.type') == 'oembed' && Arr::get($requestData, 'media.player') != 'fluent_player') ||
843 Arr::get($requestData, 'media.type') == 'iframe_html'
844 )
845 ) {
846 if (Arr::get($requestData, 'media.type') == 'iframe_html') {
847 // The UI only offers the "HTML Code" embed to moderators
848 // (_VideoEmbeder.vue passes has_iframe="is_admin"). That is a hint, not a
849 // control, so the same rule is enforced here. Reaching this branch without
850 // the permission means the field was posted straight to the REST API, so
851 // the embed is dropped rather than stored.
852 if (!self::canEmbedRawHtml($requestData, $data, $existingFeed)) {
853 return [$data, $uplaodedDocs];
854 }
855
856 $mediaPreview = array_filter(Arr::get($requestData, 'media', []));
857
858 // Moderators are trusted to embed, not to bypass sanitization: the markup
859 // still goes through the same allowlist the oembed branch below uses.
860 if (!empty($mediaPreview['html'])) {
861 $mediaPreview['html'] = RemoteUrlParser::sanitizeOembedHtml($mediaPreview['html']);
862 $mediaPreview = array_filter($mediaPreview);
863 }
864
865 if (empty($mediaPreview['image']) && !empty($mediaPreview['html'])) {
866 $thumb = RemoteUrlParser::extractIframeThumbnail($mediaPreview['html']);
867 if ($thumb) {
868 $mediaPreview['image'] = $thumb;
869 }
870 }
871
872 $data['meta']['media_preview'] = $mediaPreview;
873 return [$data, $uplaodedDocs];
874 }
875
876 $media = Arr::get($requestData, 'media');
877 $url = Arr::get($media, 'url');
878 $metaData = RemoteUrlParser::parse($url);
879 if ($metaData && !is_wp_error($metaData) && (!empty($metaData['image']) || !empty($metaData['html']))) {
880 $data['meta']['media_preview'] = $metaData;
881 }
882
883 return [$data, $uplaodedDocs];
884 }
885
886 // Let's handle the uploaded media
887 $mediaImages = Arr::get($requestData, 'media_images', []);
888 if ($mediaImages) {
889 $uploadedImages = Helper::getMediaByProvider($mediaImages);
890 if (!$existingFeed) {
891 $uploadedMediaItems = Helper::getMediaItemsFromUrl($uploadedImages);
892 } else {
893 $uploadedMediaItems = [];
894 foreach ($mediaImages as $mediaImage) {
895 $url = sanitize_url(Arr::get($mediaImage, 'url', ''));
896 if (!$url) {
897 continue;
898 }
899 $mediaItem = Helper::getMediaFromUrl($mediaImage);
900 if ($mediaItem) {
901 $uploadedMediaItems[] = $mediaItem;
902 } else {
903 // maybe this is a previously uploaded image
904 $media = Media::where('media_url', $url)
905 ->where('object_source', 'feed')
906 ->where('feed_id', $existingFeed->id)
907 ->where('is_active', 1)
908 ->first();
909
910 if ($media) {
911 $uploadedMediaItems[] = $media;
912 }
913 }
914 }
915 }
916
917 if (count($uploadedMediaItems) == 1) {
918 $singleMedia = $uploadedMediaItems[0];
919 $data['meta']['media_preview'] = [
920 'is_uploaded' => true,
921 'image' => $singleMedia->public_url,
922 'type' => 'meta_data',
923 'provider' => 'uploader',
924 'width' => Arr::get($singleMedia->settings, 'width'),
925 'height' => Arr::get($singleMedia->settings, 'height'),
926 'media_id' => $singleMedia->id,
927 ];
928 } else if ($uploadedMediaItems) {
929 $mediaPreviews = [];
930 foreach ($uploadedMediaItems as $mediaItem) {
931 $mediaData = [
932 'media_id' => $mediaItem->id,
933 'url' => $mediaItem->public_url,
934 'type' => 'image',
935 'width' => Arr::get($mediaItem->settings, 'width'),
936 'height' => Arr::get($mediaItem->settings, 'height'),
937 'provider' => Arr::get($mediaItem->settings, 'provider', 'uploader')
938 ];
939 $mediaPreviews[] = array_filter($mediaData);
940 }
941 $data['meta']['media_items'] = $mediaPreviews;
942 }
943
944 $maxMediaPerPost = apply_filters('fluent_community/max_media_per_post', Utility::getCustomizationSetting('max_media_per_post'));
945
946 $allMediaItems = array_slice($uploadedMediaItems, 0, (int)$maxMediaPerPost);
947
948 return [$data, $allMediaItems];
949 }
950
951 if ($existingFeed && Arr::get($existingFeed->meta, 'auto_flagged') == 'yes') {
952 $data['meta']['auto_flagged'] = 'yes';
953 $data['meta']['prevent_published'] = 'yes';
954 $data['meta']['reports_count'] = Arr::get($existingFeed->meta, 'reports_count', 0);
955 }
956
957 // Let's handle the fallback here
958 $firstUrl = FeedsHelper::findFirstUrl(Arr::get($data, 'message_rendered'));
959
960 // check if this is another post or not
961 if (strpos($firstUrl, Helper::baseUrl()) === 0) {
962 // this is an internal URL
963 if (Helper::getRouteNameByRequestPath($firstUrl) === 'feed_view') {
964 $uriParts = explode('/', $firstUrl);
965 if (count($uriParts) >= 2) {
966 $postSlug = end($uriParts);
967 $feed = Feed::where('slug', $postSlug)->first();
968 if ($feed) {
969 $firstUrl = null;
970 $data['meta']['custom_app_preview'] = [
971 'app_name' => 'child_post',
972 'feed_id' => $feed->id
973 ];
974 }
975 }
976 }
977 }
978
979 if ($firstUrl) {
980 $metaData = RemoteUrlParser::parse($firstUrl);
981 if ($metaData && !is_wp_error($metaData) && (!empty($metaData['image']) || !empty($metaData['html']))) {
982 $data['meta']['media_preview'] = $metaData;
983 }
984 }
985
986 $uplaodedDocs = apply_filters('fluent_community/feed/uploaded_feed_medias', $uplaodedDocs, $requestData);
987 return [$data, $uplaodedDocs];
988 }
989
990 protected static function tranformFeedData(Feed $feed, $config = [])
991 {
992 $userId = Arr::get($config, 'user_id', 0);
993 $commentLikeIds = $userId ? Arr::get($config, 'comment_like_ids', []) : [];
994
995 $feed->comments->each(function ($comment) use ($commentLikeIds) {
996 self::setCurrentRelatedUserId($comment->user_id);
997 if ($commentLikeIds && in_array($comment->id, $commentLikeIds)) {
998 $comment->liked = 1;
999 }
1000 });
1001
1002 // User-specific processing
1003 if ($userId) {
1004 $interactions = Arr::get($config, 'interactions', []);
1005
1006 if ($interactions) {
1007 $feed->has_user_react = Arr::get($interactions, 'like', false);
1008 $feed->bookmarked = Arr::get($interactions, 'bookmark', false);
1009 }
1010
1011 if ($feed->content_type == 'survey') {
1012 $votedOptions = $feed->getSurveyCastsByUserId($userId);
1013 if ($votedOptions) {
1014 $surveyConfig = Arr::get($feed->meta, 'survey_config', []);
1015 foreach ($surveyConfig['options'] as $index => $option) {
1016 if (in_array($option['slug'], $votedOptions)) {
1017 $surveyConfig['options'][$index]['voted'] = true;
1018 }
1019 }
1020 $meta = $feed->meta;
1021 $meta['survey_config'] = $surveyConfig;
1022 $feed->meta = $meta;
1023 }
1024 }
1025 }
1026
1027 if ($feed->content_type == 'document') {
1028 $feedMeta = $feed->meta;
1029 $documentLists = Arr::get($feedMeta, 'document_lists', []);
1030 foreach ($documentLists as $index => $document) {
1031 $documentLists[$index]['url'] = Helper::baseUrl('?fcom_action=download_document&media_key=' . $document['media_key'] . '&media_id=' . $document['id']);
1032 }
1033 $feedMeta['document_lists'] = $documentLists;
1034 $feed->meta = $feedMeta;
1035 }
1036
1037 $spaceSettings = $feed->space ? $feed->space->settings : [];
1038 $feed->default_comment_sort_by = Arr::get($spaceSettings, 'default_comment_sort_by', '');
1039
1040 self::setCurrentRelatedUserId($feed->user_id);
1041
1042 return apply_filters('fluent_community/rendering_feed_model', $feed, $config);
1043 }
1044
1045 public static function transformFeed(Feed $feed)
1046 {
1047 $userId = get_current_user_id();
1048
1049 $config = apply_filters('fluent_community/feed_general_config', [
1050 'user_id' => $userId,
1051 'interactions' => [],
1052 'comment_like_ids' => [],
1053 'is_collection' => false
1054 ], $feed, $userId);
1055
1056 if ($userId) {
1057 $config['interactions'] = [
1058 'like' => $feed->hasUserReact($userId, 'like'),
1059 'bookmark' => $feed->hasUserReact($userId, 'bookmark'),
1060 ];
1061 $config['comment_like_ids'] = self::getLikedIdsByUserFeedId($feed->id, $userId);
1062 }
1063
1064 return self::tranformFeedData($feed, $config);
1065 }
1066
1067 public static function transformFeedsCollection($feeds)
1068 {
1069 if ($feeds->isEmpty()) {
1070 return $feeds;
1071 }
1072
1073 $userId = get_current_user_id();
1074 $commentLikeIds = [];
1075 $formattedInteractions = [];
1076 $feedIds = $feeds->pluck('id')->toArray();
1077
1078 if ($userId) {
1079 $interactions = Reaction::query()
1080 ->select(['user_id', 'type', 'object_id'])
1081 ->whereIn('object_id', $feedIds)
1082 ->where('object_type', 'feed')
1083 ->where('user_id', $userId)
1084 ->whereIn('type', ['like', 'bookmark'])
1085 ->get();
1086
1087 $formattedInteractions = [];
1088 foreach ($interactions as $interaction) {
1089 $objectId = (int)$interaction->object_id;
1090
1091 if (!isset($formattedInteractions[$objectId])) {
1092 $formattedInteractions[$objectId] = [];
1093 }
1094 $formattedInteractions[$objectId][$interaction->type] = true;
1095 }
1096
1097 $commentLikeIds = Reaction::select('object_id')
1098 ->where('object_type', 'comment')
1099 ->whereIn('parent_id', $feedIds)
1100 ->where('user_id', $userId)
1101 ->get()
1102 ->pluck('object_id')
1103 ->toArray();
1104 }
1105
1106 $generalConfig = apply_filters('fluent_community/feed_general_config', [
1107 'user_id' => $userId,
1108 'interactions' => [],
1109 'comment_like_ids' => $commentLikeIds,
1110 'is_collection' => true
1111 ], $feeds, $feedIds);
1112
1113 $feeds->each(function ($feed) use ($userId, $generalConfig, $formattedInteractions) {
1114 $config = $generalConfig;
1115 if ($userId) {
1116 $config['interactions'] = Arr::get($formattedInteractions, $feed->id, []);
1117 }
1118 return self::tranformFeedData($feed, $config);
1119 });
1120
1121 return $feeds;
1122 }
1123
1124 public static function getMediaHtml($meta, $postPermalink)
1125 {
1126 $mediaImage = Arr::get($meta, 'media_preview.image');
1127 $mediaCount = 0;
1128 if (!$mediaImage) {
1129 $mediaItems = Arr::get($meta, 'media_items', []);
1130 if ($mediaItems) {
1131 $mediaImage = Arr::get($mediaItems[0], 'url');
1132 $mediaCount = count($mediaItems);
1133 }
1134 }
1135
1136 $feedHtml = '';
1137
1138 if ($mediaImage) {
1139 $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>';
1141 if ($mediaCount > 1) {
1142 /* translators: %d is the number of additional images not shown in the preview. */
1143 $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 }
1145 $feedHtml .= '</div>';
1146 }
1147
1148 return $feedHtml;
1149 }
1150
1151 public static function hasEveryoneTag($message)
1152 {
1153 // Updated regular expression to match @everyone with more flexibility
1154 $pattern = '/(?<=^|\W)@everyone(?=\W|\z)/iu';
1155
1156 return preg_match($pattern, $message) === 1;
1157 }
1158
1159 public static function replaceImageUrlsWithRealMediaArchive($markdown, $existingFeed = null)
1160 {
1161 $imageUrls = self::getInlineImageUrls($markdown);
1162
1163 if (!$imageUrls) {
1164 return [$markdown, []];
1165 }
1166
1167 $mediaItems = [];
1168
1169 foreach ($imageUrls as $url) {
1170 $url = sanitize_url($url);
1171 $media = Helper::getMediaFromUrl($url);
1172 if ($media) {
1173 if ($media->is_active && (!$existingFeed || $media->feed_id != $existingFeed->id) ) {
1174 continue;
1175 }
1176
1177 $realUrl = $media->public_url;
1178 $markdown = str_replace($url, $realUrl, $markdown);
1179 $mediaItems[] = $media;
1180 }
1181 }
1182
1183 return [$markdown, $mediaItems];
1184 }
1185
1186 private static function getInlineImageUrls($markdown)
1187 {
1188 $urls = [];
1189 // Match ![alt](url) and ![alt](url "title")
1190 if (preg_match_all('/!\[[^\]]*\]\(([^\s\)]+)(?:\s+"[^"]*")?\)/', $markdown, $matches)) {
1191 $urls = array_merge($urls, $matches[1]);
1192 }
1193
1194 // Match reference-style image usage: ![alt][id] and map only those IDs to their definitions [id]: url
1195 if (preg_match_all('/!\[[^\]]*\]\[([^\]]+)\]/', $markdown, $imageRefMatches)) {
1196 $usedIds = array_unique($imageRefMatches[1]);
1197
1198 if (preg_match_all('/^\[([^\]]+)\]:\s+(\S+)/m', $markdown, $refMatches)) {
1199 $refMap = [];
1200 foreach ($refMatches[1] as $index => $id) {
1201 $refMap[$id] = $refMatches[2][$index];
1202 }
1203
1204 foreach ($usedIds as $id) {
1205 if (isset($refMap[$id])) {
1206 $urls[] = $refMap[$id];
1207 }
1208 }
1209 }
1210 }
1211
1212 // Match HTML <img> tags
1213 if (preg_match_all('/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $markdown, $matches)) {
1214 $urls = array_merge($urls, $matches[1]);
1215 }
1216
1217 return array_unique($urls);
1218 }
1219 }
1220