type === 'course_lesson' && $feed->space_id) { $course = BaseSpace::withoutGlobalScopes()->find($feed->space_id); if ($course && $course->created_by) { return (int) $course->created_by; } } return (int) $feed->user_id; } public static function getSpaceSlugsByUserId($userId) { if (!$userId) { $userId = get_current_user_id(); } if (!$userId) { return []; } $user = User::find($userId); return $user->spaces()->pluck('slug')->toArray(); } /** * Statuses where a post is fully reachable by its direct link. An unlisted post is * hidden from listings only, so it stays commentable and reactable like a published one. * * @return array */ public static function getViewableByLinkStatuses() { return ['published', 'unlisted']; } /** * Row types that opt IN to comments through meta.enable_comments, mapped to the value * assumed when the key is absent. * * A feed post uses the opposite convention - meta.comments_disabled, absent meaning on - * so it is deliberately not listed here and falls through to the permissive default. * * The fallbacks match each model's getDefaultMeta(): a lesson written before the * setting existed keeps its thread, a page does not. Guessing one value for both * would silently switch off every legacy lesson discussion. * * @return array */ public static function getOptInCommentTypes() { return apply_filters('fluent_community/opt_in_comment_types', [ 'course_lesson' => 'yes', 'space_page' => 'no', ]); } /** * Whether a row accepts comments at all, by its own settings. * * This is the setting check only - it says nothing about who the current user is. * Space membership and the course level kill switch are separate, in * CommentsController::verifySpacePermission(). * * Both the read and the write path go through here so they cannot disagree. They used * to: the write path only ever read meta.comments_disabled, which pages and lessons * do not set, so a POST landed a comment on a page whose thread the UI was hiding. * * @param \FluentCommunity\App\Models\Feed $feed * @return bool */ public static function commentsEnabled($feed) { $meta = $feed->meta; if (Arr::get($meta, 'comments_disabled') === 'yes') { return false; } $optIn = self::getOptInCommentTypes(); if (isset($optIn[$feed->type])) { return Arr::get($meta, 'enable_comments', $optIn[$feed->type]) === 'yes'; } return true; } public static function getLastFeedId() { $lastItem = Feed::where('status', 'published') ->byUserAccess(get_current_user_id()) ->orderBy('id', 'DESC') ->first(); if ($lastItem) { return $lastItem->id; } return 1; } public static function mdToHtml($text, $options = []) { if (!$text) { return ''; } $text = str_replace(' ', '', $text); // hide markdown empty content $html = (new \FluentCommunity\App\Services\Parsedown([ ])) ->setBreaksEnabled(true) ->setUrlsLinked(false) // ->setSafeMode(true) ->text($text); if (!Arr::get($options, 'disable_link_process')) { // add nofollow to all links. But check if nofollow is already there $html = self::addNoFollowToLinks($html); } $html = wp_kses($html, array( 'p' => array(), 'br' => array(), 'strong' => array(), 'em' => array(), 'hr' => array(), 'h1' => array(), 'h2' => array(), 'h3' => array(), 'h4' => array(), 'h5' => array(), 'h6' => array(), 'ul' => array(), 'b' => array(), 'ol' => array(), 'li' => array(), 'span' => array(), 'a' => array( 'href' => true, 'title' => true, 'rel' => true, 'target' => true, ), 'img' => array( 'src' => true, 'alt' => true, ), 'code' => array(), 'pre' => array(), 'blockquote' => array(), 'del' => array(), 'table' => array(), 'thead' => array(), 'tbody' => array(), 'tfoot' => array(), 'tr' => array(), 'th' => array( 'align' => true, 'style' => true, 'colspan' => true, 'rowspan' => true, ), 'td' => array( 'align' => true, 'style' => true, 'colspan' => true, 'rowspan' => true, ), )); return self::maybeTransformDynamicCodes($html); } public static function maybeTransformDynamicCodes($html) { // check if there has {{ if (strpos($html, '{{') === false) { return $html; } return preg_replace_callback( '/{{utc:(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})}}/', function ($match) { // Extract the datetime string (e.g., 2025-06-01 15:06:59) $datetimeStr = $match[1]; try { // Create a DateTime object from the UTC string $date = new \DateTime($datetimeStr, new \DateTimeZone('UTC')); // Get the Unix timestamp for the data-timestamp attribute $timestamp = $date->getTimestamp(); // Format the display string $displayFormat = $date->format('d F Y, H:i') . ' (UTC)'; // Return the formatted HTML return '' . $displayFormat . ''; } catch (\Exception $e) { // Return original match if parsing fails return $match[0]; } }, $html ); } public static function addNoFollowToLinks($html) { if (!$html) { return ''; } $current_domain = wp_parse_url(home_url(), PHP_URL_HOST); // Regular expression to match tags $pattern = '/]*href=("|\')(https?:\/\/(?!' . preg_quote($current_domain, '/') . ').*?)("|\')\s?([^>]*)>/i'; // Callback function to modify each matched tag $callback = function ($matches) { $url = $matches[2]; $attr = $matches[4]; // Remove existing rel attribute if present $attr = preg_replace('/\srel=("|\').*?("|\')/i', '', $attr); // Add nofollow return ''; }; // Perform the replacement return preg_replace_callback($pattern, $callback, $html); } public static function addNewTabToLinks($html) { if (empty($html) || !is_string($html)) { return ''; } // return is there has no href if (strpos($html, 'href=') === false) { return $html; } // More comprehensive regex to capture existing attributes $pattern = '/]*)>/i'; // Callback function to modify each matched tag $callback = function ($matches) { $full_tag = $matches[0]; $attributes = $matches[1]; // Extract href preg_match('/href=("|\')([^"\']+)("|\')/', $full_tag, $href_matches); if (empty($href_matches)) { return $full_tag; } $url = $href_matches[2]; // Check if it's an external URL and not an image if (preg_match('/^https?:\/\//i', $url) && !preg_match('/\.(jpg|jpeg|png|gif|svg)$/i', $url)) { // Check if target already exists if (!preg_match('/\btarget=/i', $full_tag)) { // Preserve existing attributes, add target="_blank" return ''; } } // Return original tag if no modification needed return $full_tag; }; // Perform the replacement return preg_replace_callback($pattern, $callback, $html); } public static function findFirstUrl($html) { if (!preg_match_all('/]*?\s+)?href=([\'"])(.*?)\1/i', $html, $matches)) { return ''; } $profileUrlPrefix = Helper::baseUrl('u/'); foreach ($matches[2] as $href) { // Rendered HTML encodes "&" as "&". Left encoded, "?a=1&b=2" is read // as a parameter named "amp;b" — which makes YouTube drop the "list" param. // Re-sanitized because decoding also restores quotes and angle brackets, // and this value is fetched remotely and stored on the feed. $href = sanitize_url(html_entity_decode($href, ENT_QUOTES | ENT_HTML5, 'UTF-8')); // sanitize_url() empties a disallowed scheme. Returning that would report // "no links" for the whole post and skip any later, usable link. if (!$href || strpos($href, $profileUrlPrefix) === 0) { continue; } return $href; } return ''; } public static function extractHashTags($text, $limit = 5) { // Extract hashtag including - and _ preg_match_all('/#([a-zA-Z0-9_-]+)/', $text, $matches); $tags = array_unique($matches[1]); if (!$tags) { return []; } $tags = array_slice($tags, 0, $limit); $lowerCaseTags = array_map('strtolower', $tags); $terms = Term::whereIn('slug', $lowerCaseTags) ->where('taxonomy_name', 'hashtag') ->get(); $termIds = []; foreach ($terms as $term) { $termIds[$term->slug] = $term->id; } if (count($termIds) == count($tags)) { return array_values($termIds); } $excepts = array_diff($tags, array_keys($termIds)); foreach ($excepts as $except) { $term = Term::create([ 'taxonomy_name' => 'hashtag', 'slug' => strtolower($except), 'title' => $except ]); $termIds[$term->slug] = $term->id; } return array_values($termIds); } public static function getMentions($text, $spaceId = null, $withUsers = false) { // the mention may have . or _ or - in the username preg_match_all('/@([a-zA-Z0-9_.-]+)/', $text, $matches); $mentions = array_unique($matches[1]); if (!$mentions) { return null; } if ($spaceId) { $xProfiles = XProfile::whereIn('username', $mentions) ->whereHas('spaces', function ($query) use ($spaceId) { $query->withoutGlobalScopes()->where('space_id', $spaceId); }) ->get(); } else { $xProfiles = XProfile::whereIn('username', $mentions) ->get(); } if ($xProfiles->isEmpty()) { return null; } $userMentions = []; $userIds = []; foreach ($xProfiles as $xProfile) { $userIds[] = $xProfile->user_id; $html = '' . $xProfile->display_name . ''; $userMentions['@' . $xProfile->username] = $html; } $data = [ 'user_ids' => $userIds, 'text' => strtr($text, $userMentions) ]; if ($withUsers) { $data['users'] = User::whereIn('ID', $userIds)->get(); } return $data; } public static function getLikedIdsByUserFeedId($feedId, $userId) { return Reaction::select('object_id') ->where('object_type', 'comment') ->where('parent_id', $feedId) ->where('user_id', $userId) ->get() ->pluck('object_id') ->toArray(); } public static function castSurveyVote($newVoteIndexes, Feed $feed, $userId) { $surveyConfig = Arr::get($feed->meta, 'survey_config', []); $slugs = array_map(function ($item) { return $item['slug']; }, $surveyConfig['options']); $newVoteIndexes = array_filter(array_intersect($slugs, $newVoteIndexes)); $previousVotes = Reaction::where('type', 'survey_vote') ->where('user_id', $userId) ->where('object_id', $feed->id) ->get(); $removedIndexes = []; $alreadyIndexes = []; foreach ($previousVotes as $previousVote) { if (!in_array($previousVote->object_type, $newVoteIndexes)) { // This vote need to be deleted $removedIndexes[] = $previousVote->object_type; $previousVote->delete(); } else { $alreadyIndexes[] = $previousVote->object_type; } } $newSyncIndexes = array_diff($newVoteIndexes, $alreadyIndexes); foreach ($newSyncIndexes as $newSyncIndex) { Reaction::create([ 'user_id' => $userId, 'object_id' => $feed->id, 'type' => 'survey_vote', 'object_type' => $newSyncIndex ]); } if (!empty($newSyncIndexes)) { do_action('fluent_community/feed/cast_survey_vote', $newSyncIndexes, $feed, $userId); } foreach ($surveyConfig['options'] as $index => $option) { $slug = $option['slug']; if (in_array($slug, $removedIndexes)) { $newCount = (int)Arr::get($option, 'vote_counts', 0) - 1; $option['vote_counts'] = $newCount > 0 ? $newCount : 0; } else if (in_array($slug, $newSyncIndexes)) { $newCount = (int)Arr::get($option, 'vote_counts', 0) + 1; $option['vote_counts'] = $newCount > 0 ? $newCount : 0; } $surveyConfig['options'][$index] = $option; } $surveyConfig = apply_filters('fluent_community/feed/updated_survey_config', $surveyConfig, $feed, $userId); $meta = $feed->meta; $meta['survey_config'] = $surveyConfig; $feed->meta = $meta; $feed->save(); Utility::forgetCache('survey_cast_' . $feed->id . '_' . $userId); return $feed; } /** * Create a new feed programmatically * @param array $allData * @return \FluentCommunity\App\Models\Feed|\WP_Error **/ public static function createFeed($allData) { if (!is_array($allData)) { return new \WP_Error('invalid_data', __('Invalid data. The data need to be array', 'fluent-community'), ['status' => 400]); } $acceptedKeys = ['message', 'title', 'user_id', 'space_id', 'type']; $feedData = Arr::only($allData, $acceptedKeys); // Let's validate the data $validation = Validator::make($feedData, [ 'message' => 'required', 'title' => 'nullable|string', 'user_id' => 'required|integer|exists:users,ID', 'space_id' => 'nullable|integer|exists:fcom_spaces,id' ]); if ($validation->fails()) { return new \WP_Error('validation_failed', __('Validation failed', 'fluent-community'), $validation->errors()); } $sanitizedData = self::sanitizeAndValidateData($feedData); $feedData = wp_parse_args($sanitizedData, $feedData); $user = User::find($feedData['user_id']); if (!$user) { return new \WP_Error('user_not_found', __('User not found', 'fluent-community'), ['status' => 400]); } $user->syncXProfile(); if ($user->xprofile->status != 'active') { return new \WP_Error('user_inactive', __('User status is not active', 'fluent-community'), $validation->errors()); } $markdown = $feedData['message']; $mentions = null; // Extra Validaton for space_id if (!empty($feedData['space_id'])) { if (!Helper::isUserInSpace($feedData['user_id'], $feedData['space_id'])) { return new \WP_Error('invalid_space', __('User is not in the space', 'fluent-community'), ['status' => 400]); } $mentions = FeedsHelper::getMentions($markdown, Arr::get($feedData, 'space_id'), true); if ($mentions) { $markdown = $mentions['text']; } } else if (!Helper::hasGlobalPost()) { return new \WP_Error('global_post_disabled', 'User is not allowed to post in global', ['status' => 400]); } $feedData['message_rendered'] = wp_kses_post(self::mdToHtml($markdown)); $feedData['status'] = 'published'; if (Arr::get($allData, 'meta.media_preview.provider') == 'inline') { $allData['meta']['media_preview']['provider'] = 'giphy'; } [$feedData, $mediaItems] = self::processFeedMetaData($feedData, $allData); if ($mentions) { $feedData['meta']['mentioned_user_ids'] = Arr::get($mentions, 'user_ids', []); } $data = apply_filters('fluent_community/feed/new_feed_data', $feedData, $allData); if (is_wp_error($data)) { return $data; } $feed = new Feed(); $feed->fill($data); $feed->save(); if ($mentions) { do_action('fluent_community/feed_mentioned', $feed, $mentions['users']); } if ($mediaItems) { foreach ($mediaItems as $media) { $media->feed_id = $feed->id; $media->is_active = 1; $media->object_source = 'feed'; $media->save(); } } do_action('fluent_community/feed/created', $feed); if ($feed->space_id) { do_action('fluent_community/space_feed/created', $feed); } return $feed; } public static function sanitizeAndValidateData($data) { $message = CustomSanitizer::unslashMarkdown(trim((string) Arr::get($data, 'message', ''))); // Decode HTML entities and strip all whitespace for validation $messageForValidation = html_entity_decode($message, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $messageForValidation = preg_replace('/\s+/u', '', $messageForValidation); if (!$messageForValidation) { throw new UnprocessableEntityHttpException( esc_html__('Message is required', 'fluent-community'), 'feed_message_required' ); } $processedData = [ 'message' => $message, 'type' => 'text' ]; $survey = Arr::get($data, 'survey', []); if ($survey) { $options = Arr::get($survey, 'options', []); $formattedOptions = []; foreach ($options as $index => $option) { if (empty($option['label'])) { continue; } $formattedOptions[] = [ 'label' => sanitize_text_field($option['label']), 'slug' => Arr::get($option, 'slug') ?: 'opt_' . ($index + 1) ]; } $endDate = Arr::get($survey, 'end_date', ''); if ($endDate) { $endDate = gmdate('Y-m-d H:i:s', strtotime($endDate)); } else { $endDate = ''; } if ($formattedOptions) { $processedData['survey'] = [ 'type' => Arr::get($survey, 'type') == 'single_choice' ? 'single_choice' : 'multi_choice', 'options' => $formattedOptions, 'end_date' => $endDate ]; } } $maxlen = apply_filters('fluent_community/max_post_length', 15000); if (\strlen($message) > $maxlen) { throw new UnprocessableEntityHttpException( /* translators: %s is the maximum allowed character count */ esc_html(sprintf(__('The post is too long. Please keep it under %s characters.', 'fluent-community'), number_format($maxlen))), 'feed_message_too_long' ); } $titlePref = Utility::postTitlePref(); if ($titlePref) { $processedData['title'] = sanitize_text_field(Arr::get($data, 'title')); if ($titlePref == 'required' && empty($processedData['title'])) { throw new UnprocessableEntityHttpException( esc_html__('Title is required. Please provide a title', 'fluent-community'), 'feed_title_required' ); } // trim the title if it's too long to 192 chars (multibyte-safe; column is VARCHAR(192) characters) if (mb_strlen($processedData['title']) > 192) { $processedData['title'] = mb_substr($processedData['title'], 0, 192, 'UTF-8'); } } return $processedData; } public static function getSurveyOptionsUpdateError($existingSurveyOptions, $submittedSurvey) { if (empty($existingSurveyOptions) || empty($submittedSurvey)) { return null; } $submittedLabelsBySlug = []; foreach (Arr::get($submittedSurvey, 'options', []) as $option) { $slug = Arr::get($option, 'slug', ''); if ($slug !== '') { $submittedLabelsBySlug[$slug] = trim((string)Arr::get($option, 'label', '')); } } foreach ($existingSurveyOptions as $existingOption) { $slug = Arr::get($existingOption, 'slug', ''); if ($slug === '') { continue; } if (!isset($submittedLabelsBySlug[$slug]) || $submittedLabelsBySlug[$slug] === '') { return __('Existing poll options cannot be removed or left empty.', 'fluent-community'); } } return null; } public static function transformForEdit($feed) { $topicsConfig = Helper::getTopicsConfig(); $terms = $feed->terms; $feed->topic_ids = $terms->where('taxonomy_name', 'post_topic')->pluck('id')->toArray(); if ($topicsConfig['max_topics_per_post'] == 1) { if ($feed->topic_ids) { $feed->topic_ids = Arr::first($feed->topic_ids); } else { $feed->topic_ids = ''; } } if (Arr::get($feed->meta, 'send_announcement_email') == 'yes') { $feed->send_announcement_email = 'yes'; } if ($feed->content_type == 'document') { $documents = Media::where('object_source', 'space_document') ->where('feed_id', $feed->id) ->where('is_active', 1) ->get(); $mediaIds = []; foreach ($documents as $document) { /** @var Media $document */ $mediaIds[] = $document->getPrivateFileMeta(); } $feed->document_ids = $mediaIds; $feed->load('space'); return $feed; } $surveyConfig = Arr::get($feed->meta, 'survey_config', []); if ($surveyConfig) { $feed->survey = [ 'type' => Arr::get($surveyConfig, 'type'), 'options' => Arr::get($surveyConfig, 'options', []), 'end_date' => Arr::get($surveyConfig, 'end_date', '') ]; } $mediaImages = Arr::get($feed->meta, 'media_items', []); $meta = $feed->meta; unset($feed->meta); if ($mediaImages) { $feed->media_images = $mediaImages; } else if ($mediaPreview = Arr::get($meta, 'media_preview')) { $type = Arr::get($mediaPreview, 'type'); if ($type == 'oembed' || $type == 'iframe_html') { $feed->media = $mediaPreview; } // Only fetch the specific attached media, not all media (which would include inline images). $mediaId = Arr::get($mediaPreview, 'media_id'); if ($mediaId && $type != 'oembed' && $type != 'iframe_html') { $media = Media::where('id', $mediaId) ->where('feed_id', $feed->id) ->where('is_active', 1) ->first(); if ($media) { $feed->media_images = [[ 'url' => $media->public_url, 'type' => 'image', 'media_id' => $media->id, 'width' => Arr::get($media->settings, 'width'), 'height' => Arr::get($media->settings, 'height'), 'provider' => Arr::get($media->settings, 'provider', 'uploader') ]]; } } else if ($type != 'meta_data') { $feed->meta = $meta; } } // Preserve multi-audio so the edit composer can load, edit/remove, and re-save them // (transformForEdit otherwise drops meta for audio-only posts). $audioMedias = Arr::get($meta, 'audio_medias', []); if ($audioMedias) { $editMeta = (isset($feed->meta) && is_array($feed->meta)) ? $feed->meta : []; $editMeta['audio_medias'] = $audioMedias; $feed->meta = $editMeta; } $feed->load('space'); return $feed; } /** * Whether the current request may attach a raw "HTML Code" (iframe_html) embed. * * Mirrors the frontend rule in _VideoEmbeder.vue, which exposes that editor tab only * when is_admin is true — i.e. community_moderator globally or within the target * space. Programmatic creation is judged on the supplied author's permission rather * than the HTTP session, so integrations work without a logged-in user. Defaults to * denying when no user can be established at all. * * @param array $requestData Raw request payload. * @param array $data Feed data being assembled. * @param \FluentCommunity\App\Models\Feed|null $existingFeed Set when editing. * @return bool */ private static function canEmbedRawHtml($requestData, $data, $existingFeed = null) { // FeedsController::store()/update() already resolved this against the target space. $precomputed = Arr::get($requestData, 'is_admin'); if ($precomputed !== null) { return (bool)$precomputed; } // Every other caller resolves it here, against the post's author where one has // been established server-side (createFeed() takes user_id from its caller), and // the current user otherwise. Read from $data and never $requestData: the author // is assigned by the controller, so a request cannot nominate whose permission // gets checked. $userId = (int)Arr::get($data, 'user_id'); if (!$userId) { $userId = get_current_user_id(); } $user = $userId ? User::find($userId) : null; if (!$user) { return false; } $space = null; if ($existingFeed) { $space = $existingFeed->space; } elseif ($spaceId = (Arr::get($data, 'space_id') ?: Arr::get($requestData, 'space_id'))) { $space = BaseSpace::find($spaceId); } return (bool)$user->hasPermissionOrInCurrentSpace('community_moderator', $space); } public static function processFeedMetaData($data, $requestData, $existingFeed = null) { if (empty($data['meta'])) { $data['meta'] = []; } $uplaodedDocs = []; // Handle Survey if (!empty($data['survey'])) { $surveyConfig = $data['survey']; if ($existingFeed) { $surveyConfig = Arr::get($existingFeed->meta, 'survey_config', []); if ($surveyConfig) { $oldOptions = Arr::get($surveyConfig, 'options', []); $newOptions = Arr::get($data['survey'], 'options', []); $oldKeyedOptions = []; foreach ($oldOptions as $option) { $oldKeyedOptions[$option['slug']] = $option; } foreach ($newOptions as $index => $option) { $slug = Arr::get($option, 'slug', ''); if (isset($oldKeyedOptions[$slug])) { $newOptions[$index]['vote_counts'] = Arr::get($oldKeyedOptions[$slug], 'vote_counts', 0); } } $surveyConfig['options'] = $newOptions; } else { $surveyConfig = $data['survey']; } } if ($endDate = Arr::get($data['survey'], 'end_date', '')) { $surveyConfig['end_date'] = gmdate('Y-m-d H:i:s', strtotime($endDate)); } else { $surveyConfig['end_date'] = ''; } $data['meta']['survey_config'] = $surveyConfig; $data['content_type'] = 'survey'; unset($data['survey']); } // Handle Giphy if (Arr::get($requestData, 'meta.media_preview.provider') == 'giphy') { $url = Arr::get($requestData, 'meta.media_preview.image'); if (!$url || !filter_var($url, FILTER_VALIDATE_URL)) { return [$data, $uplaodedDocs]; } $data['meta']['media_preview'] = array_filter([ 'image' => sanitize_url($url), 'type' => sanitize_text_field(Arr::get($requestData, 'meta.media_preview.type', 'image')), 'provider' => 'giphy', 'height' => (int)Arr::get($requestData, 'meta.media_preview.height', 0), 'width' => (int)Arr::get($requestData, 'meta.media_preview.width', 0), ]); return [$data, $uplaodedDocs]; } // Handling Video Embed if ( Arr::get($requestData, 'media') && ( (Arr::get($requestData, 'media.type') == 'oembed' && Arr::get($requestData, 'media.player') != 'fluent_player') || Arr::get($requestData, 'media.type') == 'iframe_html' ) ) { if (Arr::get($requestData, 'media.type') == 'iframe_html') { // The UI only offers the "HTML Code" embed to moderators // (_VideoEmbeder.vue passes has_iframe="is_admin"). That is a hint, not a // control, so the same rule is enforced here. Reaching this branch without // the permission means the field was posted straight to the REST API, so // the embed is dropped rather than stored. if (!self::canEmbedRawHtml($requestData, $data, $existingFeed)) { return [$data, $uplaodedDocs]; } $mediaPreview = array_filter(Arr::get($requestData, 'media', [])); // Moderators are trusted to embed, not to bypass sanitization: the markup // still goes through the same allowlist the oembed branch below uses. if (!empty($mediaPreview['html'])) { $mediaPreview['html'] = RemoteUrlParser::sanitizeOembedHtml($mediaPreview['html']); // Keep only if a usable