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 +975 -51 1.0.952.11.0 View file →
@@ -2,18 +2,50 @@
2 2
3 3 namespace FluentCommunity\App\Services;
4 4
5 5 use FluentCommunity\App\Functions\Utility;
6 +use FluentCommunity\App\Models\BaseSpace;
6 7 use FluentCommunity\App\Models\Feed;
8 +use FluentCommunity\App\Models\Media;
7 9 use FluentCommunity\App\Models\Reaction;
8 10 use FluentCommunity\App\Models\Term;
9 11 use FluentCommunity\App\Models\User;
10 12 use FluentCommunity\App\Models\XProfile;
13 +use FluentCommunity\Framework\Foundation\Exceptions\UnprocessableEntityHttpException;
11 14 use FluentCommunity\Framework\Support\Arr;
12 15 use FluentCommunity\Framework\Validator\Validator;
13 16
14 17 class FeedsHelper
15 18 {
19 + static protected $currentRelatedUserIds = [];
20 +
21 + public static function setCurrentRelatedUserId($userId)
22 + {
23 + self::$currentRelatedUserIds[] = $userId;
24 + }
25 +
26 + public static function getCurrentRelatedUserIds()
27 + {
28 + return array_values(array_unique(self::$currentRelatedUserIds));
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 +
16 48 public static function getSpaceSlugsByUserId($userId)
17 49 {
18 50 if (!$userId) {
19 51 $userId = get_current_user_id();
@@ -27,8 +59,71 @@
27 59
28 60 return $user->spaces()->pluck('slug')->toArray();
29 61 }
30 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 +
31 126 public static function getLastFeedId()
32 127 {
33 128 $lastItem = Feed::where('status', 'published')
34 129 ->byUserAccess(get_current_user_id())
@@ -46,13 +141,16 @@
46 141 {
47 142 if (!$text) {
48 143 return '';
49 144 }
145 +
50 146 $text = str_replace('&#x20;', '', $text); // hide markdown empty content
51 147
52 148 $html = (new \FluentCommunity\App\Services\Parsedown([
53 149 ]))
54 150 ->setBreaksEnabled(true)
151 + ->setUrlsLinked(false)
152 + // ->setSafeMode(true)
55 153 ->text($text);
56 154
57 155 if (!Arr::get($options, 'disable_link_process')) {
58 156 // add nofollow to all links. But check if nofollow is already there
@@ -58,11 +156,93 @@
58 156 // add nofollow to all links. But check if nofollow is already there
59 157 $html = self::addNoFollowToLinks($html);
60 158 }
61 159
62 - return $html;
160 + $html = wp_kses($html, array(
161 + 'p' => array(),
162 + 'br' => array(),
163 + 'strong' => array(),
164 + 'em' => array(),
165 + 'hr' => array(),
166 + 'h1' => array(),
167 + 'h2' => array(),
168 + 'h3' => array(),
169 + 'h4' => array(),
170 + 'h5' => array(),
171 + 'h6' => array(),
172 + 'ul' => array(),
173 + 'b' => array(),
174 + 'ol' => array(),
175 + 'li' => array(),
176 + 'span' => array(),
177 + 'a' => array(
178 + 'href' => true,
179 + 'title' => true,
180 + 'rel' => true,
181 + 'target' => true,
182 + ),
183 + 'img' => array(
184 + 'src' => true,
185 + 'alt' => true,
186 + ),
187 + 'code' => array(),
188 + 'pre' => array(),
189 + 'blockquote' => array(),
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 + ),
208 + ));
209 +
210 + return self::maybeTransformDynamicCodes($html);
63 211 }
64 212
213 + public static function maybeTransformDynamicCodes($html)
214 + {
215 + // check if there has {{
216 + if (strpos($html, '{{') === false) {
217 + return $html;
218 + }
219 +
220 + return preg_replace_callback(
221 + '/{{utc:(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})}}/',
222 + function ($match) {
223 + // Extract the datetime string (e.g., 2025-06-01 15:06:59)
224 + $datetimeStr = $match[1];
225 +
226 + try {
227 + // Create a DateTime object from the UTC string
228 + $date = new \DateTime($datetimeStr, new \DateTimeZone('UTC'));
229 + // Get the Unix timestamp for the data-timestamp attribute
230 + $timestamp = $date->getTimestamp();
231 + // Format the display string
232 + $displayFormat = $date->format('d F Y, H:i') . ' (UTC)';
233 +
234 + // Return the formatted HTML
235 + return '<span class="fcom_dynamic_prop" data-type="timestamp" data-timestamp="' . $timestamp . '">' . $displayFormat . '</span>';
236 + } catch (\Exception $e) {
237 + // Return original match if parsing fails
238 + return $match[0];
239 + }
240 + },
241 + $html
242 + );
243 + }
244 +
65 245 public static function addNoFollowToLinks($html)
66 246 {
67 247 if (!$html) {
68 248 return '';
@@ -67,9 +247,9 @@
67 247 if (!$html) {
68 248 return '';
69 249 }
70 250
71 - $current_domain = parse_url(home_url(), PHP_URL_HOST);
251 + $current_domain = wp_parse_url(home_url(), PHP_URL_HOST);
72 252
73 253 // Regular expression to match <a> tags
74 254 $pattern = '/<a\s[^>]*href=("|\')(https?:\/\/(?!' . preg_quote($current_domain, '/') . ').*?)("|\')\s?([^>]*)>/i';
75 255
@@ -88,17 +268,72 @@
88 268 // Perform the replacement
89 269 return preg_replace_callback($pattern, $callback, $html);
90 270 }
91 271
272 + public static function addNewTabToLinks($html)
273 + {
274 + if (empty($html) || !is_string($html)) {
275 + return '';
276 + }
277 +
278 + // return is there has no href
279 + if (strpos($html, 'href=') === false) {
280 + return $html;
281 + }
282 +
283 + // More comprehensive regex to capture existing attributes
284 + $pattern = '/<a\s+([^>]*)>/i';
285 +
286 + // Callback function to modify each matched <a> tag
287 + $callback = function ($matches) {
288 + $full_tag = $matches[0];
289 + $attributes = $matches[1];
290 +
291 + // Extract href
292 + preg_match('/href=("|\')([^"\']+)("|\')/', $full_tag, $href_matches);
293 + if (empty($href_matches)) {
294 + return $full_tag;
295 + }
296 + $url = $href_matches[2];
297 +
298 + // Check if it's an external URL and not an image
299 + if (preg_match('/^https?:\/\//i', $url) && !preg_match('/\.(jpg|jpeg|png|gif|svg)$/i', $url)) {
300 + // Check if target already exists
301 + if (!preg_match('/\btarget=/i', $full_tag)) {
302 + // Preserve existing attributes, add target="_blank"
303 + return '<a ' . $attributes . ' target="_blank" rel="noopener noreferrer">';
304 + }
305 + }
306 +
307 + // Return original tag if no modification needed
308 + return $full_tag;
309 + };
310 +
311 + // Perform the replacement
312 + return preg_replace_callback($pattern, $callback, $html);
313 + }
314 +
92 315 public static function findFirstUrl($html)
93 316 {
94 - // use regular expression to find the first URL in a href tag
95 - // do not take the url which contains /u/ in it
96 - $pattern = '/<a\s+(?:[^>]*?\s+)?href=([\'"])(?!.*\/u\/)(.*?)\1/';
97 - preg_match($pattern, $html, $matches);
317 + if (!preg_match_all('/<a\s+(?:[^>]*?\s+)?href=([\'"])(.*?)\1/i', $html, $matches)) {
318 + return '';
319 + }
98 320
99 - if (isset($matches[2])) {
100 - 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;
101 336 }
102 337
103 338 return '';
104 339 }
@@ -146,9 +381,9 @@
146 381
147 382 return array_values($termIds);
148 383 }
149 384
150 - public static function getMentions($text, $spaceId = null)
385 + public static function getMentions($text, $spaceId = null, $withUsers = false)
151 386 {
152 387 // the mention may have . or _ or - in the username
153 388 preg_match_all('/@([a-zA-Z0-9_.-]+)/', $text, $matches);
154 389 $mentions = array_unique($matches[1]);
@@ -159,9 +394,9 @@
159 394
160 395 if ($spaceId) {
161 396 $xProfiles = XProfile::whereIn('username', $mentions)
162 397 ->whereHas('spaces', function ($query) use ($spaceId) {
163 - $query->where('space_id', $spaceId);
398 + $query->withoutGlobalScopes()->where('space_id', $spaceId);
164 399 })
165 400 ->get();
166 401 } else {
167 402 $xProfiles = XProfile::whereIn('username', $mentions)
@@ -181,14 +416,18 @@
181 416 $html = '<a data-user_name="' . $xProfile->username . '" class="fcom_mention fcom_route" href="' . Helper::baseUrl('u/' . $xProfile->username . '/') . '">' . $xProfile->display_name . '</a>';
182 417 $userMentions['@' . $xProfile->username] = $html;
183 418 }
184 419
185 - $users = User::whereIn('ID', $userIds)->get();
420 + $data = [
421 + 'user_ids' => $userIds,
422 + 'text' => strtr($text, $userMentions)
423 + ];
186 424
187 - return [
188 - 'users' => $users,
189 - 'text' => strtr($text, $userMentions)
190 - ];
425 + if ($withUsers) {
426 + $data['users'] = User::whereIn('ID', $userIds)->get();
427 + }
428 +
429 + return $data;
191 430 }
192 431
193 432 public static function getLikedIdsByUserFeedId($feedId, $userId)
194 433 {
@@ -239,8 +478,12 @@
239 478 'object_type' => $newSyncIndex
240 479 ]);
241 480 }
242 481
482 + if (!empty($newSyncIndexes)) {
483 + do_action('fluent_community/feed/cast_survey_vote', $newSyncIndexes, $feed, $userId);
484 + }
485 +
243 486 foreach ($surveyConfig['options'] as $index => $option) {
244 487 $slug = $option['slug'];
245 488
246 489 if (in_array($slug, $removedIndexes)) {
@@ -253,8 +496,10 @@
253 496
254 497 $surveyConfig['options'][$index] = $option;
255 498 }
256 499
500 + $surveyConfig = apply_filters('fluent_community/feed/updated_survey_config', $surveyConfig, $feed, $userId);
501 +
257 502 $meta = $feed->meta;
258 503 $meta['survey_config'] = $surveyConfig;
259 504 $feed->meta = $meta;
260 505 $feed->save();
@@ -263,18 +508,22 @@
263 508
264 509 return $feed;
265 510 }
266 511
267 - public static function createFeed($feedData)
512 + /**
513 + * Create a new feed programmatically
514 + * @param array $allData
515 + * @return \FluentCommunity\App\Models\Feed|\WP_Error
516 + **/
517 + public static function createFeed($allData)
268 518 {
269 - if(!is_array($feedData)){
270 - throw new \Exception('Invalid data, The provided data must be an array');
519 + if (!is_array($allData)) {
520 + return new \WP_Error('invalid_data', __('Invalid data. The data need to be array', 'fluent-community'), ['status' => 400]);
271 521 }
272 522
273 - $acceptedKeys = ['message', 'title', 'user_id', 'space_id', 'type', 'media_preview'];
523 + $acceptedKeys = ['message', 'title', 'user_id', 'space_id', 'type'];
524 + $feedData = Arr::only($allData, $acceptedKeys);
274 525
275 - $feedData = Arr::only($feedData, $acceptedKeys);
276 -
277 526 // Let's validate the data
278 527 $validation = Validator::make($feedData, [
279 528 'message' => 'required',
280 529 'title' => 'nullable|string',
@@ -282,9 +531,9 @@
282 531 'space_id' => 'nullable|integer|exists:fcom_spaces,id'
283 532 ]);
284 533
285 534 if ($validation->fails()) {
286 - throw new \Exception('Validation failed', $validation->errors());
535 + return new \WP_Error('validation_failed', __('Validation failed', 'fluent-community'), $validation->errors());
287 536 }
288 537
289 538 $sanitizedData = self::sanitizeAndValidateData($feedData);
290 539
@@ -289,47 +538,53 @@
289 538 $sanitizedData = self::sanitizeAndValidateData($feedData);
290 539
291 540 $feedData = wp_parse_args($sanitizedData, $feedData);
292 541
293 - $user = User::findOrFail($feedData['user_id']);
542 + $user = User::find($feedData['user_id']);
543 +
544 + if (!$user) {
545 + return new \WP_Error('user_not_found', __('User not found', 'fluent-community'), ['status' => 400]);
546 + }
294 547 $user->syncXProfile();
295 -
296 - if($user->xprofile->status != 'active'){
297 - throw new \Exception(esc_html__('User status is not active', 'fluent-community'));
548 + if ($user->xprofile->status != 'active') {
549 + return new \WP_Error('user_inactive', __('User status is not active', 'fluent-community'), $validation->errors());
298 550 }
299 551
300 552 $markdown = $feedData['message'];
301 -
302 553 $mentions = null;
303 554
304 555 // Extra Validaton for space_id
305 556 if (!empty($feedData['space_id'])) {
306 557 if (!Helper::isUserInSpace($feedData['user_id'], $feedData['space_id'])) {
307 - throw new \Exception(esc_html__('User is not in the space', 'fluent-community'));
558 + return new \WP_Error('invalid_space', __('User is not in the space', 'fluent-community'), ['status' => 400]);
308 559 }
309 - $mentions = FeedsHelper::getMentions($markdown, Arr::get($feedData, 'space_id'));
560 + $mentions = FeedsHelper::getMentions($markdown, Arr::get($feedData, 'space_id'), true);
310 561 if ($mentions) {
311 562 $markdown = $mentions['text'];
312 563 }
313 564 } else if (!Helper::hasGlobalPost()) {
314 - throw new \Exception(esc_html__('User is not allowed to post in global', 'fluent-community'));
565 + return new \WP_Error('global_post_disabled', 'User is not allowed to post in global', ['status' => 400]);
315 566 }
316 567
317 - $feedData['message_rendered'] = wp_kses_post(selff::mdToHtml($markdown));
568 + $feedData['message_rendered'] = wp_kses_post(self::mdToHtml($markdown));
318 569 $feedData['status'] = 'published';
319 570
320 - $firstUrl = FeedsHelper::findFirstUrl($feedData['message_rendered']);
571 + if (Arr::get($allData, 'meta.media_preview.provider') == 'inline') {
572 + $allData['meta']['media_preview']['provider'] = 'giphy';
573 + }
321 574
322 - if ($firstUrl) {
323 - $metaData = RemoteUrlParser::parse($firstUrl);
324 - if ($metaData && !is_wp_error($metaData) && (!empty($metaData['image']) || !empty($metaData['html']))) {
325 - $feedData['meta'] = [
326 - 'media_preview' => $metaData
327 - ];
328 - }
575 + [$feedData, $mediaItems] = self::processFeedMetaData($feedData, $allData);
576 +
577 + if ($mentions) {
578 + $feedData['meta']['mentioned_user_ids'] = Arr::get($mentions, 'user_ids', []);
329 579 }
330 580
331 - $data = apply_filters('fluent_community/feed/new_feed_data', $feedData, $feedData);
581 + $data = apply_filters('fluent_community/feed/new_feed_data', $feedData, $allData);
582 +
583 + if (is_wp_error($data)) {
584 + return $data;
585 + }
586 +
332 587 $feed = new Feed();
333 588 $feed->fill($data);
334 589 $feed->save();
335 590
@@ -336,8 +591,17 @@
336 591 if ($mentions) {
337 592 do_action('fluent_community/feed_mentioned', $feed, $mentions['users']);
338 593 }
339 594
595 + if ($mediaItems) {
596 + foreach ($mediaItems as $media) {
597 + $media->feed_id = $feed->id;
598 + $media->is_active = 1;
599 + $media->object_source = 'feed';
600 + $media->save();
601 + }
602 + }
603 +
340 604 do_action('fluent_community/feed/created', $feed);
341 605
342 606 if ($feed->space_id) {
343 607 do_action('fluent_community/space_feed/created', $feed);
@@ -347,14 +611,24 @@
347 611 }
348 612
349 613 public static function sanitizeAndValidateData($data)
350 614 {
351 - $message = CustomSanitizer::unslashMarkdown(trim(Arr::get($data, 'message')));
352 - $type = sanitize_text_field(Arr::get($data, 'type', 'text'));
615 + $message = CustomSanitizer::unslashMarkdown(trim((string) Arr::get($data, 'message', '')));
353 616
617 + // Decode HTML entities and strip all whitespace for validation
618 + $messageForValidation = html_entity_decode($message, ENT_QUOTES | ENT_HTML5, 'UTF-8');
619 + $messageForValidation = preg_replace('/\s+/u', '', $messageForValidation);
620 +
621 + if (!$messageForValidation) {
622 + throw new UnprocessableEntityHttpException(
623 + esc_html__('Message is required', 'fluent-community'),
624 + 'feed_message_required'
625 + );
626 + }
627 +
354 628 $processedData = [
355 629 'message' => $message,
356 - 'type' => $type
630 + 'type' => 'text'
357 631 ];
358 632
359 633 $survey = Arr::get($data, 'survey', []);
360 634
@@ -367,16 +641,24 @@
367 641 }
368 642
369 643 $formattedOptions[] = [
370 644 'label' => sanitize_text_field($option['label']),
371 - 'slug' => 'opt_' . ($index + 1)
645 + 'slug' => Arr::get($option, 'slug') ?: 'opt_' . ($index + 1)
372 646 ];
373 647 }
374 648
649 + $endDate = Arr::get($survey, 'end_date', '');
650 + if ($endDate) {
651 + $endDate = gmdate('Y-m-d H:i:s', strtotime($endDate));
652 + } else {
653 + $endDate = '';
654 + }
655 +
375 656 if ($formattedOptions) {
376 657 $processedData['survey'] = [
377 - 'type' => Arr::get($survey, 'type') == 'single_choice' ? 'single_choice' : 'multi_choice',
378 - 'options' => $formattedOptions
658 + 'type' => Arr::get($survey, 'type') == 'single_choice' ? 'single_choice' : 'multi_choice',
659 + 'options' => $formattedOptions,
660 + 'end_date' => $endDate
379 661 ];
380 662 }
381 663 }
382 664
@@ -381,9 +663,13 @@
381 663 }
382 664
383 665 $maxlen = apply_filters('fluent_community/max_post_length', 15000);
384 666 if (\strlen($message) > $maxlen) {
385 - throw new \Exception(esc_html__('Post message is too long', 'fluent-community'));
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 + );
386 672 }
387 673
388 674 $titlePref = Utility::postTitlePref();
389 675
@@ -389,15 +675,653 @@
389 675
390 676 if ($titlePref) {
391 677 $processedData['title'] = sanitize_text_field(Arr::get($data, 'title'));
392 678 if ($titlePref == 'required' && empty($processedData['title'])) {
393 - 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 + );
394 683 }
395 - // trim the title if it's too long to 150 char
396 - if (\strlen($processedData['title']) > 192) {
397 - $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');
398 687 }
399 688 }
400 689
401 690 return $processedData;
691 + }
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 +
721 + public static function transformForEdit($feed)
722 + {
723 + $topicsConfig = Helper::getTopicsConfig();
724 +
725 + $terms = $feed->terms;
726 + $feed->topic_ids = $terms->where('taxonomy_name', 'post_topic')->pluck('id')->toArray();
727 + if ($topicsConfig['max_topics_per_post'] == 1) {
728 + if ($feed->topic_ids) {
729 + $feed->topic_ids = Arr::first($feed->topic_ids);
730 + } else {
731 + $feed->topic_ids = '';
732 + }
733 + }
734 +
735 + if (Arr::get($feed->meta, 'send_announcement_email') == 'yes') {
736 + $feed->send_announcement_email = 'yes';
737 + }
738 +
739 + if ($feed->content_type == 'document') {
740 + $documents = Media::where('object_source', 'space_document')
741 + ->where('feed_id', $feed->id)
742 + ->where('is_active', 1)
743 + ->get();
744 + $mediaIds = [];
745 + foreach ($documents as $document) {
746 + /** @var Media $document */
747 + $mediaIds[] = $document->getPrivateFileMeta();
748 + }
749 + $feed->document_ids = $mediaIds;
750 + $feed->load('space');
751 + return $feed;
752 + }
753 +
754 + $surveyConfig = Arr::get($feed->meta, 'survey_config', []);
755 +
756 + if ($surveyConfig) {
757 + $feed->survey = [
758 + 'type' => Arr::get($surveyConfig, 'type'),
759 + 'options' => Arr::get($surveyConfig, 'options', []),
760 + 'end_date' => Arr::get($surveyConfig, 'end_date', '')
761 + ];
762 + }
763 +
764 + $mediaImages = Arr::get($feed->meta, 'media_items', []);
765 + $meta = $feed->meta;
766 + unset($feed->meta);
767 +
768 + if ($mediaImages) {
769 + $feed->media_images = $mediaImages;
770 + } else if ($mediaPreview = Arr::get($meta, 'media_preview')) {
771 + $type = Arr::get($mediaPreview, 'type');
772 + if ($type == 'oembed' || $type == 'iframe_html') {
773 + $feed->media = $mediaPreview;
774 + }
775 +
776 + // Only fetch the specific attached media, not all media (which would include inline images).
777 + $mediaId = Arr::get($mediaPreview, 'media_id');
778 + if ($mediaId && $type != 'oembed' && $type != 'iframe_html') {
779 + $media = Media::where('id', $mediaId)
780 + ->where('feed_id', $feed->id)
781 + ->where('is_active', 1)
782 + ->first();
783 +
784 + if ($media) {
785 + $feed->media_images = [[
786 + 'url' => $media->public_url,
787 + 'type' => 'image',
788 + 'media_id' => $media->id,
789 + 'width' => Arr::get($media->settings, 'width'),
790 + 'height' => Arr::get($media->settings, 'height'),
791 + 'provider' => Arr::get($media->settings, 'provider', 'uploader')
792 + ]];
793 + }
794 + } else if ($type != 'meta_data') {
795 + $feed->meta = $meta;
796 + }
797 + }
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 +
808 + $feed->load('space');
809 + return $feed;
810 + }
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 +
859 + public static function processFeedMetaData($data, $requestData, $existingFeed = null)
860 + {
861 + if (empty($data['meta'])) {
862 + $data['meta'] = [];
863 + }
864 +
865 + $uplaodedDocs = [];
866 + // Handle Survey
867 + if (!empty($data['survey'])) {
868 + $surveyConfig = $data['survey'];
869 + if ($existingFeed) {
870 + $surveyConfig = Arr::get($existingFeed->meta, 'survey_config', []);
871 + if ($surveyConfig) {
872 + $oldOptions = Arr::get($surveyConfig, 'options', []);
873 + $newOptions = Arr::get($data['survey'], 'options', []);
874 + $oldKeyedOptions = [];
875 + foreach ($oldOptions as $option) {
876 + $oldKeyedOptions[$option['slug']] = $option;
877 + }
878 + foreach ($newOptions as $index => $option) {
879 + $slug = Arr::get($option, 'slug', '');
880 + if (isset($oldKeyedOptions[$slug])) {
881 + $newOptions[$index]['vote_counts'] = Arr::get($oldKeyedOptions[$slug], 'vote_counts', 0);
882 + }
883 + }
884 + $surveyConfig['options'] = $newOptions;
885 + } else {
886 + $surveyConfig = $data['survey'];
887 + }
888 + }
889 +
890 + if ($endDate = Arr::get($data['survey'], 'end_date', '')) {
891 + $surveyConfig['end_date'] = gmdate('Y-m-d H:i:s', strtotime($endDate));
892 + } else {
893 + $surveyConfig['end_date'] = '';
894 + }
895 +
896 + $data['meta']['survey_config'] = $surveyConfig;
897 + $data['content_type'] = 'survey';
898 + unset($data['survey']);
899 + }
900 +
901 + // Handle Giphy
902 + if (Arr::get($requestData, 'meta.media_preview.provider') == 'giphy') {
903 + $url = Arr::get($requestData, 'meta.media_preview.image');
904 + if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) {
905 + return [$data, $uplaodedDocs];
906 + }
907 +
908 + $data['meta']['media_preview'] = array_filter([
909 + 'image' => sanitize_url($url),
910 + 'type' => sanitize_text_field(Arr::get($requestData, 'meta.media_preview.type', 'image')),
911 + 'provider' => 'giphy',
912 + 'height' => (int)Arr::get($requestData, 'meta.media_preview.height', 0),
913 + 'width' => (int)Arr::get($requestData, 'meta.media_preview.width', 0),
914 + ]);
915 +
916 + return [$data, $uplaodedDocs];
917 + }
918 +
919 + // Handling Video Embed
920 + if (
921 + Arr::get($requestData, 'media') &&
922 + (
923 + (Arr::get($requestData, 'media.type') == 'oembed' && Arr::get($requestData, 'media.player') != 'fluent_player') ||
924 + Arr::get($requestData, 'media.type') == 'iframe_html'
925 + )
926 + ) {
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 +
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;
965 + return [$data, $uplaodedDocs];
966 + }
967 +
968 + $media = Arr::get($requestData, 'media');
969 + $url = Arr::get($media, 'url');
970 + $metaData = RemoteUrlParser::parse($url);
971 + if ($metaData && !is_wp_error($metaData) && (!empty($metaData['image']) || !empty($metaData['html']))) {
972 + $data['meta']['media_preview'] = $metaData;
973 + }
974 +
975 + return [$data, $uplaodedDocs];
976 + }
977 +
978 + // Let's handle the uploaded media
979 + $mediaImages = Arr::get($requestData, 'media_images', []);
980 + if ($mediaImages) {
981 + $uploadedImages = Helper::getMediaByProvider($mediaImages);
982 + if (!$existingFeed) {
983 + $uploadedMediaItems = Helper::getMediaItemsFromUrl($uploadedImages);
984 + } else {
985 + $uploadedMediaItems = [];
986 + foreach ($mediaImages as $mediaImage) {
987 + $url = sanitize_url(Arr::get($mediaImage, 'url', ''));
988 + if (!$url) {
989 + continue;
990 + }
991 + $mediaItem = Helper::getMediaFromUrl($mediaImage);
992 + if ($mediaItem) {
993 + $uploadedMediaItems[] = $mediaItem;
994 + } else {
995 + // maybe this is a previously uploaded image
996 + $media = Media::where('media_url', $url)
997 + ->where('object_source', 'feed')
998 + ->where('feed_id', $existingFeed->id)
999 + ->where('is_active', 1)
1000 + ->first();
1001 +
1002 + if ($media) {
1003 + $uploadedMediaItems[] = $media;
1004 + }
1005 + }
1006 + }
1007 + }
1008 +
1009 + if (count($uploadedMediaItems) == 1) {
1010 + $singleMedia = $uploadedMediaItems[0];
1011 + $data['meta']['media_preview'] = [
1012 + 'is_uploaded' => true,
1013 + 'image' => $singleMedia->public_url,
1014 + 'type' => 'meta_data',
1015 + 'provider' => 'uploader',
1016 + 'width' => Arr::get($singleMedia->settings, 'width'),
1017 + 'height' => Arr::get($singleMedia->settings, 'height'),
1018 + 'media_id' => $singleMedia->id,
1019 + ];
1020 + } else if ($uploadedMediaItems) {
1021 + $mediaPreviews = [];
1022 + foreach ($uploadedMediaItems as $mediaItem) {
1023 + $mediaData = [
1024 + 'media_id' => $mediaItem->id,
1025 + 'url' => $mediaItem->public_url,
1026 + 'type' => 'image',
1027 + 'width' => Arr::get($mediaItem->settings, 'width'),
1028 + 'height' => Arr::get($mediaItem->settings, 'height'),
1029 + 'provider' => Arr::get($mediaItem->settings, 'provider', 'uploader')
1030 + ];
1031 + $mediaPreviews[] = array_filter($mediaData);
1032 + }
1033 + $data['meta']['media_items'] = $mediaPreviews;
1034 + }
1035 +
1036 + $maxMediaPerPost = apply_filters('fluent_community/max_media_per_post', Utility::getCustomizationSetting('max_media_per_post'));
1037 +
1038 + $allMediaItems = array_slice($uploadedMediaItems, 0, (int)$maxMediaPerPost);
1039 +
1040 + return [$data, $allMediaItems];
1041 + }
1042 +
1043 + if ($existingFeed && Arr::get($existingFeed->meta, 'auto_flagged') == 'yes') {
1044 + $data['meta']['auto_flagged'] = 'yes';
1045 + $data['meta']['prevent_published'] = 'yes';
1046 + $data['meta']['reports_count'] = Arr::get($existingFeed->meta, 'reports_count', 0);
1047 + }
1048 +
1049 + // Let's handle the fallback here
1050 + $firstUrl = FeedsHelper::findFirstUrl(Arr::get($data, 'message_rendered'));
1051 +
1052 + // check if this is another post or not
1053 + if (strpos($firstUrl, Helper::baseUrl()) === 0) {
1054 + // this is an internal URL
1055 + if (Helper::getRouteNameByRequestPath($firstUrl) === 'feed_view') {
1056 + $uriParts = explode('/', $firstUrl);
1057 + if (count($uriParts) >= 2) {
1058 + $postSlug = end($uriParts);
1059 + $feed = Feed::where('slug', $postSlug)->first();
1060 + if ($feed) {
1061 + $firstUrl = null;
1062 + $data['meta']['custom_app_preview'] = [
1063 + 'app_name' => 'child_post',
1064 + 'feed_id' => $feed->id
1065 + ];
1066 + }
1067 + }
1068 + }
1069 + }
1070 +
1071 + if ($firstUrl) {
1072 + $metaData = RemoteUrlParser::parse($firstUrl);
1073 + if ($metaData && !is_wp_error($metaData) && (!empty($metaData['image']) || !empty($metaData['html']))) {
1074 + $data['meta']['media_preview'] = $metaData;
1075 + }
1076 + }
1077 +
1078 + $uplaodedDocs = apply_filters('fluent_community/feed/uploaded_feed_medias', $uplaodedDocs, $requestData);
1079 + return [$data, $uplaodedDocs];
1080 + }
1081 +
1082 + protected static function tranformFeedData(Feed $feed, $config = [])
1083 + {
1084 + $userId = Arr::get($config, 'user_id', 0);
1085 + $commentLikeIds = $userId ? Arr::get($config, 'comment_like_ids', []) : [];
1086 +
1087 + $feed->comments->each(function ($comment) use ($commentLikeIds) {
1088 + self::setCurrentRelatedUserId($comment->user_id);
1089 + if ($commentLikeIds && in_array($comment->id, $commentLikeIds)) {
1090 + $comment->liked = 1;
1091 + }
1092 + });
1093 +
1094 + // User-specific processing
1095 + if ($userId) {
1096 + $interactions = Arr::get($config, 'interactions', []);
1097 +
1098 + if ($interactions) {
1099 + $feed->has_user_react = Arr::get($interactions, 'like', false);
1100 + $feed->bookmarked = Arr::get($interactions, 'bookmark', false);
1101 + }
1102 +
1103 + if ($feed->content_type == 'survey') {
1104 + $votedOptions = $feed->getSurveyCastsByUserId($userId);
1105 + if ($votedOptions) {
1106 + $surveyConfig = Arr::get($feed->meta, 'survey_config', []);
1107 + foreach ($surveyConfig['options'] as $index => $option) {
1108 + if (in_array($option['slug'], $votedOptions)) {
1109 + $surveyConfig['options'][$index]['voted'] = true;
1110 + }
1111 + }
1112 + $meta = $feed->meta;
1113 + $meta['survey_config'] = $surveyConfig;
1114 + $feed->meta = $meta;
1115 + }
1116 + }
1117 + }
1118 +
1119 + if ($feed->content_type == 'document') {
1120 + $feedMeta = $feed->meta;
1121 + $documentLists = Arr::get($feedMeta, 'document_lists', []);
1122 + foreach ($documentLists as $index => $document) {
1123 + $documentLists[$index]['url'] = Helper::baseUrl('?fcom_action=download_document&media_key=' . $document['media_key'] . '&media_id=' . $document['id']);
1124 + }
1125 + $feedMeta['document_lists'] = $documentLists;
1126 + $feed->meta = $feedMeta;
1127 + }
1128 +
1129 + $spaceSettings = $feed->space ? $feed->space->settings : [];
1130 + $feed->default_comment_sort_by = Arr::get($spaceSettings, 'default_comment_sort_by', '');
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 +
1148 + self::setCurrentRelatedUserId($feed->user_id);
1149 +
1150 + return apply_filters('fluent_community/rendering_feed_model', $feed, $config);
1151 + }
1152 +
1153 + public static function transformFeed(Feed $feed)
1154 + {
1155 + $userId = get_current_user_id();
1156 +
1157 + $config = apply_filters('fluent_community/feed_general_config', [
1158 + 'user_id' => $userId,
1159 + 'interactions' => [],
1160 + 'comment_like_ids' => [],
1161 + 'is_collection' => false
1162 + ], $feed, $userId);
1163 +
1164 + if ($userId) {
1165 + $config['interactions'] = [
1166 + 'like' => $feed->hasUserReact($userId, 'like'),
1167 + 'bookmark' => $feed->hasUserReact($userId, 'bookmark'),
1168 + ];
1169 + $config['comment_like_ids'] = self::getLikedIdsByUserFeedId($feed->id, $userId);
1170 + }
1171 +
1172 + return self::tranformFeedData($feed, $config);
1173 + }
1174 +
1175 + public static function transformFeedsCollection($feeds)
1176 + {
1177 + if ($feeds->isEmpty()) {
1178 + return $feeds;
1179 + }
1180 +
1181 + $userId = get_current_user_id();
1182 + $commentLikeIds = [];
1183 + $formattedInteractions = [];
1184 + $feedIds = $feeds->pluck('id')->toArray();
1185 +
1186 + if ($userId) {
1187 + $interactions = Reaction::query()
1188 + ->select(['user_id', 'type', 'object_id'])
1189 + ->whereIn('object_id', $feedIds)
1190 + ->where('object_type', 'feed')
1191 + ->where('user_id', $userId)
1192 + ->whereIn('type', ['like', 'bookmark'])
1193 + ->get();
1194 +
1195 + $formattedInteractions = [];
1196 + foreach ($interactions as $interaction) {
1197 + $objectId = (int)$interaction->object_id;
1198 +
1199 + if (!isset($formattedInteractions[$objectId])) {
1200 + $formattedInteractions[$objectId] = [];
1201 + }
1202 + $formattedInteractions[$objectId][$interaction->type] = true;
1203 + }
1204 +
1205 + $commentLikeIds = Reaction::select('object_id')
1206 + ->where('object_type', 'comment')
1207 + ->whereIn('parent_id', $feedIds)
1208 + ->where('user_id', $userId)
1209 + ->get()
1210 + ->pluck('object_id')
1211 + ->toArray();
1212 + }
1213 +
1214 + $generalConfig = apply_filters('fluent_community/feed_general_config', [
1215 + 'user_id' => $userId,
1216 + 'interactions' => [],
1217 + 'comment_like_ids' => $commentLikeIds,
1218 + 'is_collection' => true
1219 + ], $feeds, $feedIds);
1220 +
1221 + $feeds->each(function ($feed) use ($userId, $generalConfig, $formattedInteractions) {
1222 + $config = $generalConfig;
1223 + if ($userId) {
1224 + $config['interactions'] = Arr::get($formattedInteractions, $feed->id, []);
1225 + }
1226 + return self::tranformFeedData($feed, $config);
1227 + });
1228 +
1229 + return $feeds;
1230 + }
1231 +
1232 + public static function getMediaHtml($meta, $postPermalink)
1233 + {
1234 + $mediaImage = Arr::get($meta, 'media_preview.image');
1235 + $mediaCount = 0;
1236 + if (!$mediaImage) {
1237 + $mediaItems = Arr::get($meta, 'media_items', []);
1238 + if ($mediaItems) {
1239 + $mediaImage = Arr::get($mediaItems[0], 'url');
1240 + $mediaCount = count($mediaItems);
1241 + }
1242 + }
1243 +
1244 + $feedHtml = '';
1245 +
1246 + if ($mediaImage) {
1247 + $feedHtml .= '<div class="fcom_media" style="margin-top: 20px;">';
1248 + $feedHtml .= '<a href="' . $postPermalink . '"><img src="' . $mediaImage . '" alt="" style="max-width: 100%; height: auto; display: block; margin: 0 auto 0px;" /></a>';
1249 + if ($mediaCount > 1) {
1250 + /* translators: %d is the number of additional images not shown in the preview. */
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>';
1252 + }
1253 + $feedHtml .= '</div>';
1254 + }
1255 +
1256 + return $feedHtml;
1257 + }
1258 +
1259 + public static function hasEveryoneTag($message)
1260 + {
1261 + // Updated regular expression to match @everyone with more flexibility
1262 + $pattern = '/(?<=^|\W)@everyone(?=\W|\z)/iu';
1263 +
1264 + return preg_match($pattern, $message) === 1;
1265 + }
1266 +
1267 + public static function replaceImageUrlsWithRealMediaArchive($markdown, $existingFeed = null)
1268 + {
1269 + $imageUrls = self::getInlineImageUrls($markdown);
1270 +
1271 + if (!$imageUrls) {
1272 + return [$markdown, []];
1273 + }
1274 +
1275 + $mediaItems = [];
1276 +
1277 + foreach ($imageUrls as $url) {
1278 + $url = sanitize_url($url);
1279 + $media = Helper::getMediaFromUrl($url);
1280 + if ($media) {
1281 + if ($media->is_active && (!$existingFeed || $media->feed_id != $existingFeed->id) ) {
1282 + continue;
1283 + }
1284 +
1285 + $realUrl = $media->public_url;
1286 + $markdown = str_replace($url, $realUrl, $markdown);
1287 + $mediaItems[] = $media;
1288 + }
1289 + }
1290 +
1291 + return [$markdown, $mediaItems];
1292 + }
1293 +
1294 + private static function getInlineImageUrls($markdown)
1295 + {
1296 + $urls = [];
1297 + // Match ![alt](url) and ![alt](url "title")
1298 + if (preg_match_all('/!\[[^\]]*\]\(([^\s\)]+)(?:\s+"[^"]*")?\)/', $markdown, $matches)) {
1299 + $urls = array_merge($urls, $matches[1]);
1300 + }
1301 +
1302 + // Match reference-style image usage: ![alt][id] and map only those IDs to their definitions [id]: url
1303 + if (preg_match_all('/!\[[^\]]*\]\[([^\]]+)\]/', $markdown, $imageRefMatches)) {
1304 + $usedIds = array_unique($imageRefMatches[1]);
1305 +
1306 + if (preg_match_all('/^\[([^\]]+)\]:\s+(\S+)/m', $markdown, $refMatches)) {
1307 + $refMap = [];
1308 + foreach ($refMatches[1] as $index => $id) {
1309 + $refMap[$id] = $refMatches[2][$index];
1310 + }
1311 +
1312 + foreach ($usedIds as $id) {
1313 + if (isset($refMap[$id])) {
1314 + $urls[] = $refMap[$id];
1315 + }
1316 + }
1317 + }
1318 + }
1319 +
1320 + // Match HTML <img> tags
1321 + if (preg_match_all('/<img[^>]+src=["\']([^"\']+)["\'][^>]*>/i', $markdown, $matches)) {
1322 + $urls = array_merge($urls, $matches[1]);
1323 + }
1324 +
1325 + return array_unique($urls);
402 1326 }
403 1327 }