PluginProbe
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses / 2.5.0
FluentCommunity – Ultra-Fast High-Performance Social Network, Community, LMS & Online Courses v2.5.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 1.1.0 All 77 releases
fluent-community / app / Services / Helper.php

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

2,261 lines 83.2 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\App;
6 use FluentCommunity\App\Functions\Utility;
7 use FluentCommunity\App\Hooks\Handlers\ActivationHandler;
8 use FluentCommunity\App\Models\BaseSpace;
9 use FluentCommunity\App\Models\Feed;
10 use FluentCommunity\App\Models\Space;
11 use FluentCommunity\App\Models\Media;
12 use FluentCommunity\App\Models\Meta;
13 use FluentCommunity\App\Models\SpaceUserPivot;
14 use FluentCommunity\App\Models\User;
15 use FluentCommunity\App\Models\XProfile;
16 use FluentCommunity\Framework\Support\Arr;
17 use FluentCommunity\App\Models\SpaceGroup;
18 use FluentCommunity\Modules\Course\Model\Course;
19
20 /**
21 * Helper class for various utility functions.
22 */
23 class Helper
24 {
25
26 public static function isRtl()
27 {
28 return apply_filters('fluent_community/is_rtl', is_rtl());
29 }
30
31 /**
32 * Check if POST content length exceeds PHP limits
33 *
34 * @return array|false Error array if limit exceeded, false otherwise
35 */
36 public static function checkUploadSizeError()
37 {
38 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Server variable check
39 $contentLength = isset($_SERVER['CONTENT_LENGTH']) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
40 $postMaxSize = wp_convert_hr_to_bytes(ini_get('post_max_size'));
41
42 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- read-only existence check on superglobals, no state mutation
43 if ($contentLength > 0 && ($contentLength > $postMaxSize || (empty($_FILES) && empty($_POST)))) {
44 return [
45 'message' => sprintf(
46 /* translators: %s: max size */
47 __('Upload failed: File exceeds server limit (%s). Please upload a smaller file.', 'fluent-community'),
48 size_format($postMaxSize)
49 )
50 ];
51 }
52
53 return false;
54 }
55
56 /**
57 * Get the portal slug.
58 *
59 * @return string The portal slug.
60 */
61 /**
62 * Get the portal slug.
63 *
64 * @return string The portal slug.
65 */
66 public static function getPortalSlug($forRoute = false)
67 {
68 $settings = get_option('fluent_community_settings', []);
69 if (isset($settings['slug'])) {
70 $slug = $settings['slug'];
71 } else {
72 $slug = 'portal';
73 }
74
75 if (defined('FLUENT_COMMUNITY_PORTAL_SLUG')) {
76 $slug = \FLUENT_COMMUNITY_PORTAL_SLUG;
77 }
78
79 $slug = apply_filters('fluent_community/portal_slug', $slug);
80
81 if (!$forRoute) {
82 return $slug;
83 }
84
85 $siteUrl = get_home_url();
86
87 $poralUrl = self::baseUrl('/');
88
89 $urlPath = wp_parse_url($siteUrl, PHP_URL_PATH);
90
91 if ($urlPath) {
92 // get the url without path
93 $siteUrl = str_replace($urlPath, '', $siteUrl);
94 }
95
96 $slug = str_replace($siteUrl, '', $poralUrl);
97 // remove the first and last slashes
98 return trim($slug, '/');
99 }
100
101 /**
102 * Get the portal route type.
103 *
104 * @return string The portal route type.
105 */
106 public static function getPortalRouteType()
107 {
108 return apply_filters('fluent_community/portal_route_type', 'WebHistory');
109 }
110
111 /**
112 * Check if the portal is headless.
113 *
114 * @return bool True if headless, false otherwise.
115 */
116 public static function isHeadless()
117 {
118 return apply_filters('fluent_community/portal_page_headless', false);
119 }
120
121 /**
122 * Check if the portal has a color scheme.
123 *
124 * @return bool True if has color scheme, false otherwise.
125 */
126 public static function hasColorScheme()
127 {
128 $status = Utility::isCustomizationEnabled('dark_mode');
129 return apply_filters('fluent_community/has_color_scheme', $status);
130 }
131
132 public static function isSuperAdmin($userId = null)
133 {
134 $capability = apply_filters('fluent_community/super_admin_capability', 'manage_options');
135
136 if (!$capability) {
137 return false;
138 }
139
140 if ($userId === null) {
141 $userId = get_current_user_id();
142 }
143
144 if (!$userId) {
145 return false;
146 }
147
148 return user_can($userId, $capability);
149 }
150
151 /**
152 * Check if the user is a site admin.
153 *
154 * @param int|null $userId The user ID to check. If null, checks the current user.
155 * @return bool True if the user is a site admin, false otherwise.
156 */
157 public static function isSiteAdmin($userId = null, $user = null)
158 {
159 if (self::isSuperAdmin($userId)) {
160 return true;
161 }
162
163 if (!$user) {
164 $user = self::getCurrentUser();
165 }
166
167 return $user && Arr::get($user->getPermissions(), 'community_admin');
168 }
169
170 public static function isModerator($user = null)
171 {
172 if (!$user) {
173 $user = self::getCurrentUser();
174 }
175
176 return $user && $user->hasCommunityModeratorAccess();
177 }
178
179 /**
180 * Get the URL for an asset file.
181 *
182 * @param string $file The file name.
183 * @return string The full URL to the asset.
184 */
185 public static function assetUrl($file = '')
186 {
187 return FLUENT_COMMUNITY_PLUGIN_URL . 'assets/' . $file;
188 }
189
190 /**
191 * Get the base URL for the portal.
192 *
193 * @param string $path The path to append to the base URL.
194 * @return string The full base URL.
195 */
196 public static function baseUrl($path = '')
197 {
198 $baseUrl = apply_filters('fluent_community/base_url', home_url(self::getPortalSlug()));
199 $baseUrl = rtrim($baseUrl, '/');
200
201 if (self::getPortalRouteType() != 'hash') {
202 return $baseUrl . '/' . ltrim($path, '/');
203 }
204
205 if (!$path) {
206 return $baseUrl . '/';
207 }
208
209 return $baseUrl . '/#/' . ltrim($path, '/');
210 }
211
212 public static function getAuthUrl()
213 {
214 $settings = self::generalSettings();
215
216 return Arr::get($settings, 'cutsom_auth_url', '');
217 }
218
219 /**
220 * Get the space IDs for a user.
221 *
222 * @param int|null $userId The user ID. If null, uses the current user.
223 * @return array An array of space IDs.
224 */
225 public static function getUserSpaceIds($userId = null)
226 {
227 if (!$userId) {
228 $userId = get_current_user_id();
229 }
230
231 return SpaceUserPivot::where('user_id', $userId)
232 ->where('status', 'active')
233 ->pluck('space_id')
234 ->toArray();
235 }
236
237 public static function getUserSpaces($userId = null)
238 {
239 if (!$userId) {
240 $userId = get_current_user_id();
241 }
242
243 return Space::whereHas('members', function ($query) use ($userId) {
244 $query->where('user_id', $userId);
245 })->get();
246 }
247
248 /**
249 * Check if a user is in a specific space.
250 *
251 * @param int $userId The user ID.
252 * @param int $spaceId The space ID.
253 * @return bool True if the user is in the space, false otherwise.
254 */
255 public static function isUserInSpace($userId, $spaceId)
256 {
257 if (!$userId || !$spaceId) {
258 return false;
259 }
260
261 return SpaceUserPivot::where('user_id', $userId)
262 ->where('space_id', $spaceId)
263 ->where('status', 'active')
264 ->exists();
265 }
266
267 /**
268 * Generate HTML attributes from an array.
269 *
270 * @param array $atts An array of attribute key-value pairs.
271 * @return string The generated HTML attributes string.
272 */
273 public static function attrs($atts = [])
274 {
275 $text = '';
276
277 foreach ($atts as $key => $value) {
278 $text .= "$key=\"$value\" ";
279 }
280
281 return $text;
282 }
283
284 /**
285 * Get media from a URL.
286 *
287 * @param string|array $url The URL or an array containing URL information.
288 * @return Media|null The Media object if found, null otherwise.
289 */
290 public static function getMediaFromUrl($url)
291 {
292 if (is_array($url) && isset($url['provider'])) {
293 $provider = Arr::get($url, 'provider');
294
295 if ($provider == 'giphy') {
296 return null;
297 }
298
299 $url = Arr::get($url, 'url');
300 }
301
302 if (!$url) {
303 return null;
304 }
305
306 $parsedUrl = wp_parse_url($url, PHP_URL_QUERY);
307
308 if (!$parsedUrl) {
309 return null;
310 }
311
312 // Parse the query string to get the media_key value
313 parse_str($parsedUrl, $queryParams);
314
315 $key = Arr::get($queryParams, 'media_key');
316
317 if (!$key) {
318 return null;
319 }
320
321 return Media::where('media_key', $key)->first();
322 }
323
324 public static function removeMediaByUrl($url = '', $subObjectId = null)
325 {
326 if (!$url || !$subObjectId) {
327 return;
328 }
329
330 do_action('fluent_community/remove_medias_by_url', [$url], [
331 'sub_object_id' => $subObjectId,
332 ]);
333 }
334
335 /**
336 * Get media items from multiple URLs.
337 *
338 * @param array $urls An array of URLs.
339 * @return array An array of Media objects.
340 */
341 public static function getMediaItemsFromUrl($urls)
342 {
343 $mediaItems = [];
344
345 foreach ($urls as $url) {
346 $media = self::getMediaFromUrl($url);
347
348 if ($media) {
349 $mediaItems[] = $media;
350 }
351 }
352
353 return $mediaItems;
354 }
355
356 /**
357 * Get general settings for the community.
358 *
359 * @param bool $cached Whether to use cached settings.
360 * @return array The general settings.
361 */
362 public static function generalSettings($cached = true)
363 {
364 static $settings = null;
365
366 if ($cached && $settings) {
367 return $settings;
368 }
369
370 $settings = get_option('fluent_community_settings', []);
371
372 $defaults = [
373 'site_title' => get_bloginfo('name'),
374 'slug' => 'portal',
375 'logo' => '',
376 'white_logo' => '',
377 'logo_permalink_type' => 'default',
378 'logo_permalink' => '',
379 'featured_image' => '',
380 'access' => [
381 'acess_level' => 'public', // logged_in, public, role_based
382 'access_roles' => []
383 ],
384 'auth_form_type' => 'default',
385 'explicit_registration' => 'no',
386 'disable_global_posts' => 'yes',
387 'auth_content' => 'Please login first to access this page',
388 'auth_redirect' => '',
389 'restricted_role_content' => 'Sorry, you cannot access this page. Only authorized users can access this page.',
390 'auth_url' => '',
391 'cutsom_auth_url' => self::baseUrl('?fcom_action=auth'),
392 'use_custom_signup_page' => 'no',
393 'custom_signup_url' => ''
394 ];
395
396 $settings = wp_parse_args($settings, $defaults);
397 if ($settings['auth_form_type'] != 'custom' || empty($settings['auth_form_type'])) {
398 $settings['cutsom_auth_url'] = self::baseUrl('?fcom_action=auth');
399 }
400
401 if (defined('FLUENT_COMMUNITY_PORTAL_SLUG')) {
402 $settings['slug'] = \FLUENT_COMMUNITY_PORTAL_SLUG;
403 $settings['is_slug_defined'] = true;
404 } else {
405 unset($settings['is_slug_defined']);
406 }
407
408 return $settings;
409 }
410
411 public static function hasGlobalPost()
412 {
413 $settings = self::generalSettings();
414 $status = Arr::get($settings, 'disable_global_posts', '') != 'yes';
415
416 return apply_filters('fluent_community/has_global_post', $status);
417 }
418
419 /**
420 * Check if a user can access the portal.
421 *
422 * @param int|null $userId The user ID. If null, uses the current user.
423 * @return bool True if the user can access the portal, false otherwise.
424 */
425 public static function canAccessPortal($userId = null)
426 {
427 $settings = self::generalSettings();
428 $accessLevel = Arr::get($settings, 'access.acess_level');
429
430 if ($accessLevel == 'public') {
431 return apply_filters('fluent_community/can_access_portal', true);
432 }
433
434 if (!$userId) {
435 $userId = get_current_user_id();
436 }
437
438 if (!$userId) {
439 return apply_filters('fluent_community/can_access_portal', false);
440 }
441
442 if ($accessLevel == 'logged_in') {
443 return apply_filters('fluent_community/can_access_portal', true);
444 }
445
446 if (user_can($userId, 'edit_pages')) {
447 return apply_filters('fluent_community/can_access_portal', true);
448 }
449
450 $roles = Arr::get($settings, 'access.access_roles', []);
451
452 $user = get_user_by('ID', $userId);
453
454 if (!$user) {
455 return apply_filters('fluent_community/can_access_portal', false);
456 }
457
458 $result = !!array_intersect(array_values($user->roles), $roles);
459
460 if (!$result) {
461 return apply_filters('fluent_community/can_access_portal', false);
462 }
463
464 $xProfile = Helper::getCurrentProfile();
465
466 $result = $xProfile && $xProfile->status == 'active';
467
468 return apply_filters('fluent_community/can_access_portal', $result);
469 }
470
471 /**
472 * Get the portal route paths.
473 *
474 * @return array An array of portal route paths.
475 */
476 public static function portalRoutePaths()
477 {
478 return apply_filters('fluent_community/app_route_paths', [
479 'portal_home',
480 'members',
481 'bookmarks',
482 'chat',
483 'dashboard',
484 'leaderboards',
485 'notifications',
486 'space',
487 'discover',
488 'courses',
489 'u',
490 'post',
491 'admin',
492 'course',
493 'site-maps'
494 ]);
495 }
496
497 /**
498 * Get the current user's profile.
499 *
500 * @param bool $cached Whether to use cached profile.
501 * @return XProfile|null The user's profile or null if not found.
502 */
503 public static function getCurrentProfile($cached = true)
504 {
505 static $profile;
506 if ($profile && $cached) {
507 return $profile;
508 }
509
510 $userId = get_current_user_id();
511
512 if (!$userId) {
513 $profile = null;
514 return $profile;
515 }
516
517 $profile = XProfile::where('user_id', $userId)->first();
518
519 return $profile;
520 }
521
522 /**
523 * Get the current user Model.
524 *
525 * @param bool $cached Whether to use cached user.
526 * @return User|false The User model or false if not found.
527 */
528 public static function getCurrentUser($cached = true)
529 {
530 $userId = get_current_user_id();
531 if (!$userId) {
532 return false;
533 }
534
535 static $user;
536 if ($user && $cached) {
537 return $user;
538 }
539
540 $user = User::find($userId);
541
542 return $user;
543 }
544
545 /**
546 * Get the route paths for the community.
547 *
548 * @return array An array of route paths.
549 */
550 private static function getRoutePaths()
551 {
552 return [
553 'dashboard' => '/dashboard',
554 'all_feeds' => '/',
555 'single_feed' => '/post/:feed_slug',
556 'space_feeds' => '/space/:space/home',
557 'space_feed' => '/space/:space/post/:feed_slug',
558 'space_members' => '/space/:space/members',
559 'spaces' => '/discover/spaces',
560 'settings' => '/admin/settings',
561 'admin_moderators' => '/admin/settings/moderators',
562 'all_members' => '/members',
563 'user_profile' => '/u/:username/',
564 'user_communities' => '/u/:username/spaces',
565 'update_profile' => '/u/:username/update',
566 'discussions' => '/discussions',
567 'create_topic' => '/discussions/create-topic',
568 'topic' => '/discussions/topic/:slug',
569 'notifications' => '/notifications',
570 'bookmarks' => '/bookmarks',
571 'courses' => '/courses',
572 'view_course' => '/courses/view/:course_id/lessons',
573 'view_lesson' => '/courses/view/:course_id/lessons/:lesson_slug/view',
574 'manage_courses' => '/admin/manage-courses',
575 'edit_lessons' => '/admin/manage-courses/edit/:course_id/lessons',
576 'course_students' => '/admin/manage-courses/edit/:course_id/students',
577 'course_overview' => '/admin/manage-courses/edit/:course_id/overview',
578 'manage_leaderboard' => '/admin/manage-leaderboard',
579 ];
580 }
581
582 /**
583 * Get the URL for a JavaScript route.
584 *
585 * @param array $route The route information.
586 * @return string The URL for the route.
587 */
588 public static function getUrlByJsRoute($route = [])
589 {
590 $routePaths = self::getRoutePaths();
591
592 $routeName = Arr::get($route, 'name', '');
593
594 if (!$routeName || !isset($routePaths[$routeName])) {
595 return self::baseUrl();
596 }
597
598 $path = $routePaths[$routeName];
599
600 $params = (array)Arr::get($route, 'params', []);
601
602 if (!$params) {
603 return self::baseUrl($path);
604 }
605
606 $replaces = [];
607
608 foreach ($params as $paramKey => $paramValue) {
609 $replaces[':' . $paramKey] = $paramValue;
610 }
611
612 $path = str_replace(array_keys($replaces), array_values($replaces), $path);
613
614 return self::baseUrl($path);
615
616 }
617
618 /**
619 * Get the route name from a request path.
620 *
621 * @param string $path The request path.
622 * @return string|false The route name or false if not found.
623 */
624 public static function getRouteNameByRequestPath($path)
625 {
626 $path = '//' . $path;
627
628 if (strpos($path, '/u/')) {
629 return 'user_profile';
630 }
631
632 if (strpos($path, '/post/')) {
633 return 'feed_view';
634 }
635
636 if (strpos($path, '/lessons/')) {
637 return 'lesson_view';
638 }
639
640 if (strpos($path, '/course/')) {
641 return 'course_view';
642 }
643
644 if (strpos($path, '/space/') && !strpos($path, '/discover/spaces')) {
645 return 'community_view';
646 }
647
648 if (strpos($path, '/admin')) {
649 return 'admin';
650 }
651
652 return false;
653 }
654
655 /**
656 * Get a human-readable excerpt from content.
657 *
658 * @param string $content The content to extract from.
659 * @param int $length The maximum length of the excerpt.
660 * @return string The human-readable excerpt.
661 */
662 public static function getHumanExcerpt($content, $length = 100)
663 {
664 if ($content) {
665 $patterns = [
666 '/^#{1,6}\s+/m' => '',
667 // Bold and Italic: remove '*' and '_' symbols
668 '/(\*\*|__)(.*?)\1/' => '$2',
669 '/(\*|_)(.*?)\1/' => '$2',
670 // Code blocks: remove triple backticks
671 '/^```\s*\w*\s*\n([\s\S]*?)\n```\s*$/m' => '$1',
672 // Inline code: remove single backticks
673 '/`([^`]+)`/' => '$1',
674 // Blockquotes: remove '>' symbol
675 '/^\s*>\s?/m' => '',
676 // Horizontal rules: replace with empty line
677 '/^\s*([-*_])\1{2,}\s*$/m' => "\n",
678 // Links: keep only the link text
679 '/\[([^\]]+)\]\([^\)]+\)/' => '$1',
680 // Images: keep only the alt text
681 '/!\[([^\]]+)\]\([^\)]+\)/' => '$1',
682 // Strikethrough: remove '~~' symbols
683 '/~~(.*?)~~/' => '$1',
684 // Task lists: remove checkbox syntax
685 '/^\s*[-*+]\s+\[[ xX]\]\s+/m' => '',
686 ];
687
688 $content = preg_replace(array_keys($patterns), array_values($patterns), $content);
689
690 // remove all tags
691 $content = wp_strip_all_tags($content);
692 // remove new lines and tabs
693 $content = str_replace(["\r", "\n", "\t"], ' ', $content);
694 // remove multiple spaces
695 $content = preg_replace('/\s+/', ' ', $content);
696
697 // trim
698 $content = trim($content);
699 }
700
701 if (!$content) {
702 return '';
703 }
704
705 if (mb_strlen($content) <= $length) {
706 return $content;
707 }
708
709 // return the first $length chars of the content with ... at the end
710 return mb_substr($content, 0, $length) . '...';
711 }
712
713 /**
714 * Check if the portal is publicly accessible.
715 *
716 * @return bool True if publicly accessible, false otherwise.
717 */
718 public static function isPublicAccessible()
719 {
720 $settings = self::generalSettings();
721 return Arr::get($settings, 'access.acess_level') == 'public';
722 }
723
724 /**
725 * Get media by provider.
726 *
727 * @param array $images The array of images.
728 * @param string $provider The provider to filter by.
729 * @return array The filtered array of images.
730 */
731 public static function getMediaByProvider($images, $provider = 'uploader')
732 {
733 if (is_array($images)) {
734 return array_filter($images, function ($image) use ($provider) {
735 if (is_array($image)) {
736 if (isset($image['provider'])) {
737 return Arr::get($image, 'provider') == $provider;
738 }
739
740 return $provider === 'uploader'; // for existing images when no provider was set.
741 }
742 });
743 }
744
745 return [];
746 }
747
748 /**
749 * Get the community menu groups.
750 *
751 * @param User|null $user The user to get menu groups for.
752 * @return array The community menu groups.
753 */
754 public static function getCommunityMenuGroups($user = null, $view = true)
755 {
756 if (!$user) {
757 $user = self::getCurrentUser();
758 }
759
760 $communityGroups = self::getAllCommunityGroups($user);
761
762 if ($communityGroups->isEmpty()) {
763 return [];
764 }
765
766 $userSpaceIds = $user ? self::getUserSpaceIds($user->ID) : [];
767 $isComModerator = $user && $user->hasCommunityModeratorAccess();
768 $isCourseCreator = $user && $user->hasCourseCreatorAccess();
769 $isSpaceModerator = $user && $user->isSpaceModerator();
770
771 $formattedGroups = [];
772 foreach ($communityGroups as $communityGroup) {
773 $validSpaces = [];
774 $spaces = $communityGroup->spaces;
775 $isShowAll = Arr::get($communityGroup->settings, 'always_show_spaces') === 'yes';
776
777 if (!$isShowAll && !$isSpaceModerator) {
778 $spaceIds = $spaces->pluck('id')->toArray();
779 $isNotMemberOfAnySpace = empty(array_intersect($spaceIds, $userSpaceIds));
780 if ($isNotMemberOfAnySpace) {
781 continue;
782 }
783 }
784
785 foreach ($spaces as $space) {
786 $validSpace = $view ? self::transformSpaceToLink($space, $user) : $space;
787 if (!$validSpace) {
788 continue;
789 }
790
791 if ($user && $space->isContentSpace()) {
792 $validSpace['unread_badge'] = self::getUnreadFeedsCounts($space->id);
793 }
794
795 if ($isComModerator && $space->type != 'course') {
796 $validSpaces[] = $validSpace;
797 continue;
798 }
799
800 if ($isCourseCreator && $space->type == 'course') {
801 $validSpaces[] = $validSpace;
802 continue;
803 }
804
805 if ($space->privacy == 'public') {
806 $validSpaces[] = $validSpace;
807 continue;
808 }
809
810 $hasMembership = $user && $space->getMembership($user->ID);
811
812 if ($space->privacy == 'private') {
813 if (!$user || !$hasMembership) {
814 $validSpace['show_lock'] = true;
815 }
816 }
817
818 if ($space->privacy == 'secret') {
819 if (!$user || !$hasMembership) {
820 continue;
821 }
822 }
823
824 $validSpaces[] = $validSpace;
825 }
826
827 if (!$validSpaces && !$isSpaceModerator) {
828 continue;
829 }
830
831 $formattedGroups[] = [
832 'id' => $communityGroup->id,
833 'title' => $communityGroup->title,
834 'slug' => $communityGroup->slug,
835 'logo' => $communityGroup->logo,
836 'children' => $validSpaces
837 ];
838 }
839
840 return apply_filters('fluent_community/menu_groups_for_user', $formattedGroups, $user);
841 }
842
843 public static function getUnreadFeedsCounts($spaceId, $force = false)
844 {
845 static $coutsCache = null;
846 if ($coutsCache !== null && !$force) {
847 return Arr::get((array)$coutsCache, $spaceId);
848 }
849
850 $xprofile = self::getCurrentProfile();
851
852 if (!$xprofile || !$xprofile->last_activity) {
853 return 0;
854 }
855
856 $lastActivityDate = gmdate('Y-m-d H:i:s', strtotime($xprofile->last_activity) - 300);
857
858 $lastActivityDate = apply_filters('fluent_community/last_activity_date_for_unread_feeds', $lastActivityDate, $xprofile);
859
860 $unreadCounts = Feed::query()
861 ->select('space_id', Utility::getApp('db')->raw('COUNT(*) as feed_count'))
862 ->where('status', 'published')
863 ->where('created_at', '>', $lastActivityDate)
864 ->groupBy('space_id')
865 ->get();
866
867 $coutsCache = [];
868 foreach ($unreadCounts as $unreadCount) {
869 $coutsCache[$unreadCount->space_id] = $unreadCount->feed_count > 10 ? '10+' : $unreadCount->feed_count;
870 }
871
872 return Arr::get($coutsCache, $spaceId);
873 }
874
875 /**
876 * Transform a space to a link array.
877 *
878 * @param Space $space The space to transform.
879 * @param User|null $user The user to check permissions for.
880 * @return array|null The transformed space link array.
881 */
882 private static function transformSpaceToLink($space, $user = null)
883 {
884 $isCustomLink = $space->type == 'sidebar_link';
885 if ($isCustomLink && !self::canViewSideLinkLink($space, $user)) {
886 return null;
887 }
888
889 $logo = $space->logo;
890 $title = $space->title;
891
892 if ($space->status == 'draft') {
893 $title = $title . ' ' . __('(Draft)', 'fluent-community');
894 }
895
896 return [
897 'title' => $title,
898 'icon_image' => $logo,
899 'shape_svg' => !$logo ? Arr::get($space->settings, 'shape_svg', '') : '',
900 'emoji' => !$logo ? Arr::get($space->settings, 'emoji', '') : '',
901 'permalink' => $space->getPermalink(),
902 'is_custom' => $isCustomLink ? 'yes' : 'no',
903 'new_tab' => ($isCustomLink && Arr::get($space, 'settings.new_tab', 'no') === 'yes') ? 'yes' : 'no',
904 'link_classes' => 'space_menu_item route_url fcom_space_id_' . $space->id . ' fcom_space_' . $space->slug
905 ];
906 }
907
908 public static function canViewSideLinkLink($space, $user = null)
909 {
910 $privacy = $space->privacy;
911
912 if ($privacy == 'public') {
913 return true;
914 }
915
916 if ($privacy == 'logged_in') {
917 return !!$user;
918 }
919
920 if ($privacy == 'logged_out_only') {
921 return !$user;
922 }
923
924 if (!$user) {
925 return false;
926 }
927
928 $accessIds = Arr::get($space->settings, 'membership_ids', []);
929
930 if (!$accessIds) {
931 return true;
932 }
933
934 $userSpaces = $user->getSpaceIds();
935 return !!array_intersect($userSpaces, $accessIds);
936 }
937
938 public static function isAlreadyOnboarded()
939 {
940 $communitySettings = get_option('fluent_community_settings', []);
941
942 return !empty($communitySettings);
943 }
944
945 /**
946 * Get all community groups.
947 *
948 * @param User $user The user to get groups for.
949 * @param bool $willCreate Whether to create a default group if none exist.
950 * @return \FluentCommunity\Framework\Support\Collection Collection of Groups
951 */
952 public static function getAllCommunityGroups($user, $willCreate = true)
953 {
954 $isModerator = $user && $user->isCommunityModerator();
955
956 $communityGroups = SpaceGroup::query()->orderBy('serial', 'ASC')
957 ->with([
958 'spaces' => function ($query) use ($isModerator) {
959 if ($isModerator) {
960 $query->orderBy('serial', 'ASC');
961 } else {
962 $query->where('status', 'published')
963 ->orderBy('serial', 'ASC');
964 }
965 }
966 ])
967 ->get();
968
969 if ($communityGroups->isEmpty() && $willCreate) {
970 $createdSpace = (new ActivationHandler(App::make()))->maybeCreateDefaultSpaceGroup();
971
972 if ($createdSpace) {
973 Space::where('type', 'community')->update([
974 'parent_id' => $createdSpace->id
975 ]);
976 }
977
978 return self::getAllCommunityGroups($user, false);
979 }
980
981 return $communityGroups;
982 }
983
984 /**
985 * Check if a feature is enabled.
986 *
987 * @param string $feature The feature to check.
988 * @return bool True if the feature is enabled, false otherwise.
989 */
990 public static function isFeatureEnabled($feature)
991 {
992 $features = Utility::getFeaturesConfig();
993
994 return isset($features[$feature]) && $features[$feature] === 'yes';
995 }
996
997 /**
998 * Get the menu items group.
999 *
1000 * @param string $context The context for getting menu items.
1001 * @return array The menu items group.
1002 */
1003 public static function getMenuItemsGroup($context = 'view')
1004 {
1005 static $menuGroups;
1006
1007 if ($menuGroups && $context === 'view') {
1008 return $menuGroups;
1009 }
1010
1011 $menuGroups = (array) Utility::getOption('fluent_community_menu_groups', []);
1012
1013 $membersPageStatus = Utility::canViewMembersPage() ? 'yes' : 'no';
1014
1015 $leaderboardPageVisibility = (Utility::canViewLeaderboardMembers() || is_user_logged_in()) ? 'yes' : 'no';
1016
1017 $defaultMainMenuItems = [
1018 'all_feeds' => [
1019 'slug' => 'all_feeds',
1020 'title' => __('Feed', 'fluent-community'),
1021 'is_system' => 'yes',
1022 'is_locked' => 'yes',
1023 'enabled' => 'yes',
1024 'permalink' => self::baseUrl('/'),
1025 'link_classes' => 'fcom_dashboard route_url',
1026 'shape_svg' => '<svg width="20" height="18" viewBox="0 0 20 18" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M10 13.166H10.0075H10Z" fill="currentColor"/><path d="M10 13.166H10.0075" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M16.6666 6.08301V10.2497C16.6666 13.3924 16.6666 14.9637 15.6903 15.94C14.714 16.9163 13.1426 16.9163 9.99992 16.9163C6.85722 16.9163 5.28587 16.9163 4.30956 15.94C3.33325 14.9637 3.33325 13.3924 3.33325 10.2497V6.08301" stroke="currentColor" stroke-width="1.5"/><path d="M18.3333 7.74967L14.714 4.27925C12.4918 2.14842 11.3807 1.08301 9.99996 1.08301C8.61925 1.08301 7.50814 2.14842 5.28592 4.27924L1.66663 7.74967" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>'
1027 ],
1028 'spaces' => [
1029 'slug' => 'spaces',
1030 'title' => __('Spaces', 'fluent-community'),
1031 'is_system' => 'yes',
1032 'is_locked' => 'yes',
1033 'enabled' => 'yes',
1034 'permalink' => self::baseUrl('discover/spaces'),
1035 'link_classes' => 'fcom_spaces route_url',
1036 'shape_svg' => '<svg version="1.1" viewBox="0 0 128 128" xml:space="preserve"><g><path d="M64,42c-13.2,0-24,10.8-24,24s10.8,24,24,24s24-10.8,24-24S77.2,42,64,42z M64,82c-8.8,0-16-7.2-16-16s7.2-16,16-16 s16,7.2,16,16S72.8,82,64,82z"/><path d="M64,100.8c-14.9,0-29.2,6.2-39.4,17.1l-2.7,2.9l5.8,5.5l2.7-2.9c8.8-9.4,20.7-14.6,33.6-14.6s24.8,5.2,33.6,14.6l2.7,2.9 l5.8-5.5l-2.7-2.9C93.2,107.1,78.9,100.8,64,100.8z"/><path d="M97,47.9v8c9.4,0,18.1,3.8,24.6,10.7l5.8-5.5C119.6,52.7,108.5,47.9,97,47.9z"/><path d="M116.1,20c0-10.5-8.6-19.1-19.1-19.1S77.9,9.5,77.9,20S86.5,39.1,97,39.1S116.1,30.5,116.1,20z M85.9,20 c0-6.1,5-11.1,11.1-11.1s11.1,5,11.1,11.1s-5,11.1-11.1,11.1S85.9,26.1,85.9,20z"/><path d="M31,47.9c-11.5,0-22.6,4.8-30.4,13.2l5.8,5.5c6.4-6.9,15.2-10.7,24.6-10.7V47.9z"/><path d="M50.1,20C50.1,9.5,41.5,0.9,31,0.9S11.9,9.5,11.9,20S20.5,39.1,31,39.1S50.1,30.5,50.1,20z M31,31.1 c-6.1,0-11.1-5-11.1-11.1S24.9,8.9,31,8.9s11.1,5,11.1,11.1S37.1,31.1,31,31.1z"/></g></svg>'
1037 ],
1038 'all_courses' => [
1039 'slug' => 'all_courses',
1040 'title' => __('Courses', 'fluent-community'),
1041 'link_classes' => 'fcom_courses route_url',
1042 'is_system' => 'yes',
1043 'is_locked' => 'yes',
1044 'enabled' => 'yes',
1045 'is_unavailable' => self::isFeatureEnabled('course_module') ? 'no' : 'yes',
1046 'permalink' => self::baseUrl('courses'),
1047 'shape_svg' => '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10.734 5.84746L14.7114 6.9072M9.88139 9.01146L11.8701 9.54132M9.98031 14.9723L10.7758 15.1843C13.0258 15.7838 14.1508 16.0835 15.037 15.5747C15.9233 15.0659 16.2247 13.9473 16.8276 11.71L17.6802 8.54599C18.2831 6.3087 18.5845 5.19006 18.0728 4.30879C17.5611 3.42752 16.4362 3.12778 14.1862 2.52831L13.3907 2.31636C11.1407 1.71688 10.0157 1.41714 9.12948 1.92594C8.24322 2.43474 7.94178 3.55338 7.3389 5.79067L6.4863 8.95466C5.88342 11.1919 5.58198 12.3106 6.09367 13.1919C6.60536 14.0731 7.73034 14.3729 9.98031 14.9723Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M9.99996 17.4559L9.20634 17.672C6.96165 18.2832 5.83931 18.5889 4.95512 18.0701C4.07093 17.5513 3.7702 16.4107 3.16874 14.1295L2.31814 10.9035C1.71668 8.62232 1.41595 7.48174 1.92643 6.58318C2.36802 5.80591 3.33329 5.83421 4.58329 5.83411" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>'
1048 ],
1049 'all_members' => [
1050 'slug' => 'all_members',
1051 'title' => __('Members', 'fluent-community'),
1052 'is_system' => 'yes',
1053 'is_locked' => 'yes',
1054 'is_unavailable' => $membersPageStatus == 'yes' ? 'no' : 'yes',
1055 'enabled' => $membersPageStatus,
1056 'permalink' => self::baseUrl('members'),
1057 'link_classes' => 'fcom_all_members route_url',
1058 'shape_svg' => '<svg width="20" height="16" viewBox="0 0 20 16" fill="none"><path d="M17.3116 13C17.936 13 18.4327 12.6071 18.8786 12.0576C19.7915 10.9329 18.2927 10.034 17.721 9.59383C17.1399 9.14635 16.4911 8.89285 15.8332 8.83333M14.9999 7.16667C16.1505 7.16667 17.0832 6.23393 17.0832 5.08333C17.0832 3.93274 16.1505 3 14.9999 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M2.68822 13C2.0638 13 1.56714 12.6071 1.12121 12.0576C0.208326 10.9329 1.70714 10.034 2.27879 9.59383C2.8599 9.14635 3.50874 8.89285 4.16659 8.83333M4.58325 7.16667C3.43266 7.16667 2.49992 6.23393 2.49992 5.08333C2.49992 3.93274 3.43266 3 4.58325 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M6.73642 10.592C5.88494 11.1185 3.65241 12.1936 5.01217 13.5389C5.6764 14.196 6.41619 14.666 7.34627 14.666H12.6536C13.5837 14.666 14.3234 14.196 14.9877 13.5389C16.3474 12.1936 14.1149 11.1185 13.2634 10.592C11.2667 9.35735 8.73313 9.35735 6.73642 10.592Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12.9166 4.24967C12.9166 5.86051 11.6107 7.16634 9.99992 7.16634C8.38909 7.16634 7.08325 5.86051 7.08325 4.24967C7.08325 2.63884 8.38909 1.33301 9.99992 1.33301C11.6107 1.33301 12.9166 2.63884 12.9166 4.24967Z" stroke="currentColor" stroke-width="1.5"/></svg>',
1059 ],
1060 'leaderboard' => [
1061 'slug' => 'leaderboard',
1062 'is_system' => 'yes',
1063 'is_locked' => 'yes',
1064 'enabled' => $leaderboardPageVisibility,
1065 'is_unavailable' => self::isFeatureEnabled('leader_board_module') && $leaderboardPageVisibility == 'yes' ? 'no' : 'yes',
1066 'title' => __('Leaderboard', 'fluent-community'),
1067 'link_classes' => 'fcom_leaderboards route_url',
1068 'permalink' => self::baseUrl('leaderboards'),
1069 'shape_svg' => '<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="currentColor"><path d="M160-200h160v-320H160v320Zm240 0h160v-560H400v560Zm240 0h160v-240H640v240ZM80-120v-480h240v-240h320v320h240v400H80Z"/></svg>'
1070 ]
1071 ];
1072
1073 $mainItems = Arr::get($menuGroups, 'mainMenuItems', []);
1074
1075 if ($mainItems && is_array($mainItems)) {
1076 if (isset($mainItems['all_communities'])) {
1077 $mainItems['spaces'] = $defaultMainMenuItems['spaces'];
1078 unset($mainItems['all_communities']);
1079 }
1080
1081 foreach ($mainItems as $index => &$item) {
1082 if (empty($item['slug'])) {
1083 unset($mainItems[$index]);
1084 continue;
1085 }
1086 $defaultItem = Arr::get($defaultMainMenuItems, $item['slug'], []);
1087 if ($defaultItem) {
1088 $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug'];
1089 foreach ($preservedKeys as $key) {
1090 if (isset($defaultItem[$key])) {
1091 $item[$key] = Arr::get($defaultItem, $key);
1092 }
1093 }
1094 if (Arr::get($defaultItem, 'is_system') === 'yes') {
1095 $item['permalink'] = $defaultItem['permalink'];
1096 $item['link_classes'] = $defaultItem['link_classes'];
1097 if (empty($item['shape_svg'])) {
1098 $item['shape_svg'] = $defaultItem['shape_svg'];
1099 }
1100 }
1101 }
1102 }
1103 } else {
1104 $mainItems = $defaultMainMenuItems;
1105 }
1106
1107 $defaultProfileDropDownItems = [
1108 'my_spaces' => [
1109 'slug' => 'my_spaces',
1110 'title' => __('My Spaces', 'fluent-community'),
1111 'is_system' => 'yes',
1112 'is_locked' => 'yes',
1113 'enabled' => 'yes',
1114 'permalink' => '#{{user_url}}/spaces',
1115 'shape_svg' => '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0.5 16.5C0.5 14.9087 1.13214 13.3826 2.25736 12.2574C3.38258 11.1321 4.9087 10.5 6.5 10.5C8.0913 10.5 9.61742 11.1321 10.7426 12.2574C11.8679 13.3826 12.5 14.9087 12.5 16.5H11C11 15.3065 10.5259 14.1619 9.68198 13.318C8.83807 12.4741 7.69347 12 6.5 12C5.30653 12 4.16193 12.4741 3.31802 13.318C2.47411 14.1619 2 15.3065 2 16.5H0.5ZM6.5 9.75C4.01375 9.75 2 7.73625 2 5.25C2 2.76375 4.01375 0.75 6.5 0.75C8.98625 0.75 11 2.76375 11 5.25C11 7.73625 8.98625 9.75 6.5 9.75ZM6.5 8.25C8.1575 8.25 9.5 6.9075 9.5 5.25C9.5 3.5925 8.1575 2.25 6.5 2.25C4.8425 2.25 3.5 3.5925 3.5 5.25C3.5 6.9075 4.8425 8.25 6.5 8.25ZM12.713 11.0273C13.767 11.5019 14.6615 12.2709 15.2889 13.2418C15.9164 14.2126 16.2501 15.344 16.25 16.5H14.75C14.7502 15.633 14.4999 14.7844 14.0293 14.0562C13.5587 13.328 12.8878 12.7512 12.0972 12.3953L12.7123 11.0273H12.713ZM12.197 2.55975C12.9526 2.87122 13.5987 3.40015 14.0533 4.07942C14.5078 4.75869 14.7503 5.55768 14.75 6.375C14.7503 7.40425 14.3658 8.39642 13.6719 9.15662C12.978 9.91682 12.025 10.3901 11 10.4835V8.97375C11.5557 8.89416 12.0713 8.63851 12.471 8.24434C12.8707 7.85017 13.1335 7.33824 13.2209 6.7837C13.3082 6.22916 13.2155 5.66122 12.9563 5.16327C12.6971 4.66531 12.2851 4.26356 11.7808 4.017L12.197 2.55975Z" fill="currentColor"/></svg>'
1116 ],
1117 'bookmarks' => [
1118 'slug' => 'bookmarks',
1119 'title' => __('Bookmarks', 'fluent-community'),
1120 'is_system' => 'yes',
1121 'is_locked' => 'yes',
1122 'enabled' => 'yes',
1123 'permalink' => self::baseUrl('bookmarks'),
1124 'shape_svg' => '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0.75 0.5H11.25C11.4489 0.5 11.6397 0.579018 11.7803 0.71967C11.921 0.860322 12 1.05109 12 1.25V15.6073C12.0001 15.6743 11.9822 15.7402 11.9482 15.7979C11.9142 15.8557 11.8653 15.9033 11.8066 15.9358C11.7479 15.9683 11.6816 15.9844 11.6146 15.9826C11.5476 15.9807 11.4823 15.9609 11.4255 15.9252L6 12.5225L0.5745 15.9245C0.517776 15.9601 0.452541 15.9799 0.385576 15.9818C0.318612 15.9837 0.252365 15.9676 0.193721 15.9352C0.135078 15.9029 0.0861801 15.8554 0.0521121 15.7977C0.0180441 15.74 4.98531e-05 15.6742 0 15.6073V1.25C0 1.05109 0.0790178 0.860322 0.21967 0.71967C0.360322 0.579018 0.551088 0.5 0.75 0.5ZM10.5 2H1.5V13.574L6 10.7533L10.5 13.574V2Z" fill="currentColor"/></svg>'
1125 ],
1126 'logout' => [
1127 'slug' => 'logout',
1128 'title' => __('Logout', 'fluent-community'),
1129 'is_system' => 'yes',
1130 'is_locked' => 'yes',
1131 'enabled' => 'yes',
1132 'permalink' => '#{{logout_url}}',
1133 'shape_svg' => '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M0.75 15.5C0.551088 15.5 0.360322 15.421 0.21967 15.2803C0.0790178 15.1397 0 14.9489 0 14.75V1.25C0 1.05109 0.0790178 0.860322 0.21967 0.71967C0.360322 0.579018 0.551088 0.5 0.75 0.5H11.25C11.4489 0.5 11.6397 0.579018 11.7803 0.71967C11.921 0.860322 12 1.05109 12 1.25V3.5H10.5V2H1.5V14H10.5V12.5H12V14.75C12 14.9489 11.921 15.1397 11.7803 15.2803C11.6397 15.421 11.4489 15.5 11.25 15.5H0.75ZM10.5 11V8.75H5.25V7.25H10.5V5L14.25 8L10.5 11Z" fill="currentColor"/></svg>'
1134 ]
1135 ];
1136
1137 $profileDropDownItems = Arr::get($menuGroups, 'profileDropdownItems', []);
1138
1139 if ($profileDropDownItems && is_array($profileDropDownItems)) {
1140 unset($profileDropDownItems['profile']);
1141 foreach ($profileDropDownItems as $index => &$item) {
1142 if (empty($item['slug'])) {
1143 unset($profileDropDownItems[$index]);
1144 continue;
1145 }
1146 $defaultItem = Arr::get($defaultProfileDropDownItems, $item['slug'], []);
1147 if ($defaultItem) {
1148 $preservedKeys = ['is_system', 'is_locked', 'is_unavailable', 'slug'];
1149 foreach ($preservedKeys as $key) {
1150 if (isset($defaultItem[$key])) {
1151 $item[$key] = Arr::get($defaultItem, $key);
1152 }
1153 }
1154 if (Arr::get($defaultItem, 'is_system') === 'yes') {
1155 $item['permalink'] = $defaultItem['permalink'];
1156 if (empty($item['shape_svg'])) {
1157 $item['shape_svg'] = $defaultItem['shape_svg'];
1158 }
1159 }
1160 }
1161 }
1162 } else {
1163 $profileDropDownItems = $defaultProfileDropDownItems;
1164 }
1165
1166 $beforeCommunityMenuItems = Arr::get($menuGroups, 'beforeCommunityMenuItems', []);
1167 $afterCommunityMenuGroups = Arr::get($menuGroups, 'afterCommunityLinkGroups', []);
1168
1169 if (!is_array($beforeCommunityMenuItems)) {
1170 $beforeCommunityMenuItems = [];
1171 }
1172
1173 if (!is_array($afterCommunityMenuGroups)) {
1174 $afterCommunityMenuGroups = [];
1175 }
1176
1177 if ($context == 'view') {
1178
1179 $currentUser = self::getCurrentUser();
1180
1181 $mainItems = array_filter($mainItems, function ($item) use ($currentUser) {
1182 return self::isLinkAccessible($item, $currentUser);
1183 });
1184
1185 $profileDropDownItems = array_filter($profileDropDownItems, function ($item) use ($currentUser) {
1186 return self::isLinkAccessible($item, $currentUser);
1187 });
1188
1189 $beforeCommunityMenuItems = array_filter($beforeCommunityMenuItems, function ($item) use ($currentUser) {
1190 return self::isLinkAccessible($item, $currentUser);
1191 });
1192
1193 $validGroups = [];
1194 foreach ($afterCommunityMenuGroups as $group) {
1195 if (empty($group['items']) || !is_array($group['items'])) {
1196 continue;
1197 }
1198
1199 $group['items'] = array_filter($group['items'], function ($item) use ($currentUser) {
1200 return self::isLinkAccessible($item, $currentUser);
1201 });
1202
1203 if ($group['items']) {
1204 $validGroups[] = $group;
1205 }
1206 }
1207
1208 $afterCommunityMenuGroups = $validGroups;
1209 }
1210
1211 $menuGroups['mainMenuItems'] = $mainItems;
1212 $menuGroups['profileDropdownItems'] = $profileDropDownItems;
1213 $menuGroups['beforeCommunityMenuItems'] = $beforeCommunityMenuItems;
1214 $menuGroups['afterCommunityLinkGroups'] = $afterCommunityMenuGroups;
1215
1216 if ($context == 'view') {
1217 $menuGroups = apply_filters('fluent_community/menu_groups', $menuGroups);
1218 }
1219
1220 return $menuGroups;
1221 }
1222
1223 public static function isLinkAccessible($link, $currentUser = null)
1224 {
1225 $isEnabled = Arr::get($link, 'enabled') === 'yes';
1226 $isUnavailable = Arr::get($link, 'is_unavailable') === 'yes';
1227
1228 if (!$isEnabled || $isUnavailable) {
1229 return false;
1230 }
1231
1232 $privacy = Arr::get($link, 'privacy', '');
1233
1234 if (!$privacy || $privacy === 'public') {
1235 return true;
1236 }
1237
1238 if ($privacy == 'logged_in') {
1239 return !!$currentUser;
1240 }
1241
1242 if ($privacy == 'logged_out_only') {
1243 return !$currentUser;
1244 }
1245
1246 $membershipIds = Arr::get($link, 'membership_ids', []);
1247 if (!$membershipIds) {
1248 return true;
1249 }
1250
1251 if (!$currentUser) {
1252 return false;
1253 }
1254
1255 static $userSpacesIds = null;
1256 if ($userSpacesIds === null) {
1257 $userSpacesIds = $currentUser->getJoinedSpaceIds();
1258 }
1259
1260 return $userSpacesIds && !!array_intersect($userSpacesIds, $membershipIds);
1261 }
1262
1263 /**
1264 * Get the meta data for a space.
1265 *
1266 * @param int $spaceId The ID of the space.
1267 * @param string $key The meta key.
1268 * @param mixed $default The default value if the meta key is not found.
1269 * @return mixed The meta value or the default value if not found.
1270 */
1271 public static function getSpaceMeta($spaceId, $key, $default = null)
1272 {
1273 $meta = Meta::where('object_type', 'space')
1274 ->where('meta_key', $key)
1275 ->where('object_id', $spaceId)
1276 ->first();
1277
1278 if (!$meta) {
1279 return $default;
1280 }
1281
1282 return $meta->value;
1283 }
1284
1285 /**
1286 * Update the meta data for a space.
1287 *
1288 * @param int $spaceId The ID of the space.
1289 * @param string $key The meta key.
1290 * @param mixed $value The meta value.
1291 * @return Meta The updated meta object.
1292 */
1293 public static function updateSpaceMeta($spaceId, $key, $value)
1294 {
1295 $meta = Meta::where('object_type', 'space')
1296 ->where('meta_key', $key)
1297 ->where('object_id', $spaceId)
1298 ->first();
1299
1300 if ($meta) {
1301 $meta->value = $value;
1302 $meta->save();
1303 } else {
1304 $meta = Meta::create([
1305 'object_type' => 'space',
1306 'object_id' => $spaceId,
1307 'meta_key' => $key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
1308 'value' => $value
1309 ]);
1310 }
1311
1312 return $meta;
1313 }
1314
1315
1316 /**
1317 * Encrypt or decrypt a value.
1318 *
1319 * @param string $value The value to encrypt or decrypt.
1320 * @param string $type The type of operation ('e' for encrypt, 'd' for decrypt).
1321 * @return string|false The encrypted or decrypted value or false if an error occurs.
1322 */
1323 public static function encryptDecrypt($value, $type = 'e')
1324 {
1325 if (!$value) {
1326 return $value;
1327 }
1328
1329 if (!extension_loaded('openssl')) {
1330 return $value;
1331 }
1332
1333 if (defined('FLUENT_COM_ENCRYPT_SALT')) {
1334 $salt = FLUENT_COM_ENCRYPT_SALT;
1335 } else {
1336 $salt = (defined('LOGGED_IN_SALT') && '' !== LOGGED_IN_SALT) ? LOGGED_IN_SALT : 'this-is-a-fallback-salt-but-not-secure';
1337 }
1338
1339 if (defined('FLUENT_COM__ENCRYPT_KEY')) {
1340 $key = FLUENT_COM__ENCRYPT_KEY;
1341 } else {
1342 $key = (defined('LOGGED_IN_KEY') && '' !== LOGGED_IN_KEY) ? LOGGED_IN_KEY : 'this-is-a-fallback-key-but-not-secure';
1343 }
1344
1345 if ($type == 'e') {
1346 $method = 'aes-256-ctr';
1347 $ivlen = openssl_cipher_iv_length($method);
1348 $iv = openssl_random_pseudo_bytes($ivlen);
1349
1350 $raw_value = openssl_encrypt($value . $salt, $method, $key, 0, $iv);
1351 if (!$raw_value) {
1352 return false;
1353 }
1354
1355 return base64_encode($iv . $raw_value); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
1356 }
1357
1358 $raw_value = base64_decode($value, true); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
1359
1360 $method = 'aes-256-ctr';
1361 $ivlen = openssl_cipher_iv_length($method);
1362 $iv = substr($raw_value, 0, $ivlen);
1363
1364 $raw_value = substr($raw_value, $ivlen);
1365
1366 $newValue = openssl_decrypt($raw_value, $method, $key, 0, $iv);
1367 if (!$newValue || substr($newValue, -strlen($salt)) !== $salt) {
1368 return false;
1369 }
1370
1371 return substr($newValue, 0, -strlen($salt));
1372 }
1373
1374
1375 /**
1376 * Get the welcome banner configuration.
1377 *
1378 * @return array The welcome banner configuration.
1379 */
1380 public static function getWelcomeBannerSettings()
1381 {
1382 $defaults = [
1383 'login' => [
1384 'enabled' => 'no',
1385 'description' => '',
1386 'mediaType' => 'image',
1387 'allowClose' => 'no',
1388 'bannerImage' => '',
1389 'bannerVideo' => [
1390 'type' => 'oembed',
1391 'url' => '',
1392 'content_type' => '',
1393 'provider' => '',
1394 'title' => '',
1395 'author_name' => '',
1396 'html' => ''
1397 ],
1398 'ctaButtons' => []
1399 ],
1400 'logout' => [
1401 'enabled' => 'no',
1402 'description' => '',
1403 'mediaType' => 'image',
1404 'useCustomUrl' => 'no',
1405 'bannerImage' => '',
1406 'bannerVideo' => [
1407 'type' => 'oembed',
1408 'url' => '',
1409 'content_type' => '',
1410 'provider' => '',
1411 'title' => '',
1412 'author_name' => '',
1413 'html' => ''
1414 ],
1415 'ctaButtons' => []
1416 ]
1417 ];
1418
1419 $settings = Utility::getOption('welcome_banner_settings', []);
1420
1421 $settings = wp_parse_args($settings, $defaults);
1422
1423 if (empty(Arr::get($settings, 'login.bannerVideo'))) {
1424 $settings['login']['bannerVideo'] = $defaults['login']['bannerVideo'];
1425 }
1426
1427 if (empty(Arr::get($settings, 'logout.bannerVideo'))) {
1428 $settings['logout']['bannerVideo'] = $defaults['logout']['bannerVideo'];
1429 }
1430
1431 return $settings;
1432 }
1433
1434 public static function getWelcomeBanner($view = 'login')
1435 {
1436 $settings = self::getWelcomeBannerSettings();
1437 $welcomeBanner = Arr::get($settings, $view, []);
1438 if (Arr::get($welcomeBanner, 'enabled') != 'yes') {
1439 return null;
1440 }
1441
1442 unset($welcomeBanner['description']);
1443
1444 if ($view == 'login') {
1445 return apply_filters('fluent_community/welcome_banner_for_logged_in', $welcomeBanner);
1446 }
1447
1448 return apply_filters('fluent_community/welcome_banner_for_guests', $welcomeBanner);
1449 }
1450
1451 public static function getEnabledFeedLinks()
1452 {
1453 $links = array_filter(self::getFeedLinks(), function ($item) {
1454 return self::isLinkAccessible($item);
1455 });
1456
1457 return array_values($links);
1458 }
1459
1460 public static function getMobileMenuItems($context = 'headless')
1461 {
1462 $xprofile = Helper::getCurrentProfile();
1463
1464 $mobileMenuItems = [
1465 [
1466 'route' => [
1467 'name' => 'all_feeds'
1468 ],
1469 'icon_svg' => '<svg width="20" height="18" viewBox="0 0 20 18" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M10 13.166H10.0075H10Z" fill="currentColor"></path><path d="M10 13.166H10.0075" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M16.6666 6.08301V10.2497C16.6666 13.3924 16.6666 14.9637 15.6903 15.94C14.714 16.9163 13.1426 16.9163 9.99992 16.9163C6.85722 16.9163 5.28587 16.9163 4.30956 15.94C3.33325 14.9637 3.33325 13.3924 3.33325 10.2497V6.08301" stroke="currentColor" stroke-width="1.5"></path><path d="M18.3333 7.74967L14.714 4.27925C12.4918 2.14842 11.3807 1.08301 9.99996 1.08301C8.61925 1.08301 7.50814 2.14842 5.28592 4.27924L1.66663 7.74967" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"></path></svg>'
1470 ],
1471 [
1472 'route' => [
1473 'name' => 'spaces'
1474 ],
1475 'icon_svg' => '<svg version="1.1" viewBox="0 0 128 128" xml:space="preserve"><g><path d="M64,42c-13.2,0-24,10.8-24,24s10.8,24,24,24s24-10.8,24-24S77.2,42,64,42z M64,82c-8.8,0-16-7.2-16-16s7.2-16,16-16 s16,7.2,16,16S72.8,82,64,82z"></path><path d="M64,100.8c-14.9,0-29.2,6.2-39.4,17.1l-2.7,2.9l5.8,5.5l2.7-2.9c8.8-9.4,20.7-14.6,33.6-14.6s24.8,5.2,33.6,14.6l2.7,2.9 l5.8-5.5l-2.7-2.9C93.2,107.1,78.9,100.8,64,100.8z"></path><path d="M97,47.9v8c9.4,0,18.1,3.8,24.6,10.7l5.8-5.5C119.6,52.7,108.5,47.9,97,47.9z"></path><path d="M116.1,20c0-10.5-8.6-19.1-19.1-19.1S77.9,9.5,77.9,20S86.5,39.1,97,39.1S116.1,30.5,116.1,20z M85.9,20 c0-6.1,5-11.1,11.1-11.1s11.1,5,11.1,11.1s-5,11.1-11.1,11.1S85.9,26.1,85.9,20z"></path><path d="M31,47.9c-11.5,0-22.6,4.8-30.4,13.2l5.8,5.5c6.4-6.9,15.2-10.7,24.6-10.7V47.9z"></path><path d="M50.1,20C50.1,9.5,41.5,0.9,31,0.9S11.9,9.5,11.9,20S20.5,39.1,31,39.1S50.1,30.5,50.1,20z M31,31.1 c-6.1,0-11.1-5-11.1-11.1S24.9,8.9,31,8.9s11.1,5,11.1,11.1S37.1,31.1,31,31.1z"></path></g></svg>'
1476 ]
1477 ];
1478
1479 if ($xprofile) {
1480 $mobileMenuItems[] = [
1481 'route' => [
1482 'name' => 'user_profile',
1483 'params' => [
1484 'username' => $xprofile->username
1485 ]
1486 ],
1487 'icon_svg' => '<svg viewBox="0 0 1024 1024"><path fill="currentColor" d="M512 512a192 192 0 1 0 0-384 192 192 0 0 0 0 384m0 64a256 256 0 1 1 0-512 256 256 0 0 1 0 512m320 320v-96a96 96 0 0 0-96-96H288a96 96 0 0 0-96 96v96a32 32 0 1 1-64 0v-96a160 160 0 0 1 160-160h448a160 160 0 0 1 160 160v96a32 32 0 1 1-64 0"></path></svg>'
1488 ];
1489 } else if (!get_current_user_id()) {
1490 $mobileMenuItems[] = [
1491 'name' => 'login',
1492 'permalink' => Helper::getAuthUrl(),
1493 'icon_svg' => '<svg viewBox="0 0 1024 1024"><path fill="currentColor" d="M512 512a192 192 0 1 0 0-384 192 192 0 0 0 0 384m0 64a256 256 0 1 1 0-512 256 256 0 0 1 0 512m320 320v-96a96 96 0 0 0-96-96H288a96 96 0 0 0-96 96v96a32 32 0 1 1-64 0v-96a160 160 0 0 1 160-160h448a160 160 0 0 1 160 160v96a32 32 0 1 1-64 0"></path></svg>'
1494 ];
1495 }
1496
1497 return apply_filters('fluent_community/mobile_menu', $mobileMenuItems, $xprofile, $context);
1498 }
1499
1500 public static function getFeedLinks()
1501 {
1502 return Utility::getOption('feed_links', []);
1503 }
1504
1505 public static function updateFeedLinks($links)
1506 {
1507 Utility::updateOption('feed_links', $links);
1508 }
1509
1510 /**
1511 * Get the full name of a WordPress user.
1512 *
1513 * @param int|null $id The ID of the user.
1514 * @return string The full name of the user.
1515 */
1516 public static function getWpUserFullName($id = null)
1517 {
1518 $id = $id ?: get_current_user_id();
1519 $user = get_user_by('ID', $id);
1520
1521 $fullName = $user->display_name;
1522 if ($user->first_name && $user->last_name) {
1523 $fullName = $user->first_name . ' ' . $user->last_name;
1524 }
1525
1526 return $fullName;
1527 }
1528
1529 /**
1530 * Get the onboarding settings.
1531 *
1532 * @return array The onboarding settings.
1533 */
1534 public static function getOnboardingSettings()
1535 {
1536 $default = [
1537 'is_onboarding_enabled' => 'no',
1538 'registration_page_url' => '',
1539 ];
1540
1541 $settings = Utility::getOption('onboarding_settings', $default);
1542
1543 return wp_parse_args($settings, $default);
1544 }
1545
1546 /**
1547 * Get all WordPress published pages.
1548 *
1549 * @return array An array of page data.
1550 */
1551 public static function getAllWpPublishedPage()
1552 {
1553 $posts = get_posts(array(
1554 'post_status' => 'publish',
1555 'numberposts' => -1,
1556 'post_type' => 'any',
1557 ));
1558
1559 return array_map(function ($post) {
1560 return array(
1561 'id' => $post->ID,
1562 'permalink' => get_permalink($post),
1563 'title' => get_the_title($post),
1564 );
1565 }, $posts);
1566 }
1567
1568 /**
1569 * Add a user to a space.
1570 *
1571 * @param Space | int $space space to add the user to.
1572 * @param int $userId The ID of the user to add.
1573 * @param string $role The role of the user in the space.
1574 * @param string $by The source of the action.
1575 * @return bool True if the user was added, false otherwise.
1576 */
1577 public static function addToSpace($space, $userId, $role = 'member', $by = 'self', $skipSync = false)
1578 {
1579 if (is_numeric($space)) {
1580 $space = BaseSpace::onlyMain()->find($space);
1581 }
1582
1583 if (!$space || !$space instanceof BaseSpace) {
1584 return false;
1585 }
1586
1587 if (!$skipSync) {
1588 $user = User::find($userId);
1589
1590 if (!$user) {
1591 return false;
1592 }
1593
1594 $user->syncXProfile();
1595 }
1596
1597 if ($role == 'member' && $space->type == 'course') {
1598 $role = 'student';
1599 }
1600
1601 $exist = SpaceUserPivot::where('user_id', $userId)
1602 ->where('space_id', $space->id)
1603 ->first();
1604
1605 if ($exist) {
1606 if ($exist->status != 'active') {
1607 $exist->status = 'active';
1608
1609 if (!in_array($exist->role, ['admin', 'moderator'])) {
1610 $exist->role = $role;
1611 }
1612
1613 $exist->save();
1614
1615 if ($space->type == 'course') {
1616 do_action('fluent_community/course/enrolled', $space, $userId, $by);
1617 } else {
1618 do_action('fluent_community/space/joined', $space, $userId, $by);
1619 }
1620
1621 return true;
1622 }
1623
1624 return false;
1625 }
1626
1627 $created = SpaceUserPivot::create([
1628 'space_id' => $space->id,
1629 'role' => $role,
1630 'user_id' => $userId
1631 ]);
1632
1633 if ($space->type == 'course') {
1634 if (!$space instanceof Course) {
1635 $space = Course::find($space->id); // we are renewing the model to have access to course relations
1636 }
1637 do_action('fluent_community/course/enrolled', $space, $userId, $by, $created);
1638 } else {
1639 if (!$space instanceof Space) {
1640 $space = Space::find($space->id); // we are renewing the model to have access to space relations
1641 }
1642 do_action('fluent_community/space/joined', $space, $userId, $by, $created);
1643 }
1644 return true;
1645 }
1646
1647 /**
1648 * Remove a user from a space if exist.
1649 *
1650 * @param int $userId The ID of the user.
1651 * @param int $spaceId The ID of the space.
1652 * @param string $by The source of the action. self | by_admin
1653 * @return bool True if the user is in the space, false otherwise.
1654 */
1655 public static function removeFromSpace($space, $userId, $by = 'self')
1656 {
1657 $user = User::find($userId);
1658 if (!$user) {
1659 return false;
1660 }
1661
1662 if (is_numeric($space)) {
1663 $space = BaseSpace::query()->onlyMain()->find($space);
1664 }
1665
1666 if (!$space || !$space instanceof BaseSpace) {
1667 return false;
1668 }
1669
1670
1671 if (!self::isUserInSpace($userId, $space->id)) {
1672 return false;
1673 }
1674
1675 SpaceUserPivot::where('space_id', $space->id)
1676 ->where('user_id', $userId)
1677 ->delete();
1678
1679 $user->cacheAccessSpaces();
1680
1681 if ($space->type == 'course') {
1682 if (!$space instanceof Course) {
1683 $space = Course::find($space->id); // we are renewing the model to have access to course relations
1684 }
1685
1686 do_action('fluent_community/course/student_left', $space, $userId, $by);
1687 } else {
1688 if (!$space instanceof Space) {
1689 $space = Space::find($space->id);
1690 }
1691 // we are renewing the model to have access to space relations
1692 do_action('fluent_community/space/user_left', $space, $userId, $by);
1693 }
1694
1695 return true;
1696 }
1697
1698 /**
1699 * Render a link with icon.
1700 *
1701 * @param array $link The link data.
1702 * @param string $linkClass Additional classes for the link.
1703 * @param string $fallback The fallback content if no icon is found.
1704 * @param bool $renderIcon Whether to render the icon or not.
1705 */
1706 public static function renderLink($link, $linkClass = '', $fallback = '<span class="fcom_no_avatar"></span>', $renderIcon = true)
1707 {
1708 if (!$link || empty($link['permalink'])) {
1709 return;
1710 }
1711
1712 $isCustom = Arr::get($link, 'is_custom') == 'yes';
1713
1714 $linkAtts = array_filter([
1715 'class' => trim($linkClass . ' ' . Arr::get($link, 'link_classes')) . ' fcom_compt_link' . ($isCustom ? ' fcom_custom_link' : ''),
1716 'target' => Arr::get($link, 'new_tab') === 'yes' ? '_blank' : '',
1717 'rel' => Arr::get($link, 'new_tab') === 'yes' ? 'noopener noreferrer' : '',
1718 ]);
1719
1720 ?>
1721 <a aria-label="Go to <?php echo esc_attr(Arr::get($link, 'title')); ?> page"
1722 href="<?php echo esc_url($link['permalink']); ?>"<?php foreach ($linkAtts as $key => $value) {
1723 echo esc_attr($key) . '="' . esc_attr($value) . '"';
1724 } ?>>
1725 <?php $renderIcon && self::printLinkIcon($link, $fallback); ?>
1726 <span class="community_name"><?php echo wp_kses_post(Arr::get($link, 'title')); ?></span>
1727 <?php if (Arr::get($link, 'show_lock')) : ?>
1728 <span class="fcom_space_lock">
1729 <i class="el-icon">
1730 <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
1731 <path fill="currentColor"
1732 d="M224 448a32 32 0 0 0-32 32v384a32 32 0 0 0 32 32h576a32 32 0 0 0 32-32V480a32 32 0 0 0-32-32zm0-64h576a96 96 0 0 1 96 96v384a96 96 0 0 1-96 96H224a96 96 0 0 1-96-96V480a96 96 0 0 1 96-96"></path>
1733 <path fill="currentColor"
1734 d="M512 544a32 32 0 0 1 32 32v192a32 32 0 1 1-64 0V576a32 32 0 0 1 32-32m192-160v-64a192 192 0 1 0-384 0v64zM512 64a256 256 0 0 1 256 256v128H256V320A256 256 0 0 1 512 64"></path>
1735 </svg>
1736 </i>
1737 </span>
1738 <?php elseif ($unreadBardge = Arr::get($link, 'unread_badge')) : ?>
1739 <span class="fcom_space_lock fcom_unread_count">
1740 <?php echo wp_kses_post($unreadBardge); ?>
1741 </span>
1742 <?php endif; ?>
1743 </a>
1744 <?php
1745 }
1746
1747 public static function renderMenuItems($menuItems, $linkClass, $fallback = '', $renderIcon = false)
1748 {
1749 if (!$menuItems) {
1750 return;
1751 }
1752
1753 $renderIcon = $renderIcon || Utility::isCustomizationEnabled('icon_on_header_menu');
1754
1755 foreach ($menuItems as $itemKey => $item): ?>
1756 <li class="<?php echo esc_attr('fcom_menu_item_' . $itemKey); ?>">
1757 <?php self::renderLink($item, $linkClass, $fallback, $renderIcon); ?>
1758 </li>
1759 <?php endforeach;
1760 }
1761
1762 public static function renderSettingsItems($settingsItems = [])
1763 {
1764 foreach ($settingsItems as $itemKey => $item): ?>
1765 <li class="<?php echo esc_attr('fcom_menu_item_' . $itemKey); ?>">
1766 <a class="fcom_menu_link <?php echo esc_attr(Arr::get($item, 'link_classes')); ?>"
1767 href="<?php echo esc_url($item['permalink']); ?>">
1768 <?php if (!empty($item['icon_svg'])): ?>
1769 <i class="el-icon">
1770 <?php echo CustomSanitizer::sanitizeSvg(Arr::get($item, 'el-icon', '')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
1771 </i>
1772 <?php endif; ?>
1773 <span class="community_name"><?php echo wp_kses_post($item['title']); ?></span>
1774 </a>
1775 </li>
1776 <?php endforeach;
1777 }
1778
1779 /**
1780 * Print a link icon.
1781 *
1782 * @param array $link The link data.
1783 * @param string $fallback The fallback content if no icon is found.
1784 */
1785 public static function printLinkIcon($link, $fallback = '<span class="fcom_no_avatar"></span>')
1786 {
1787 ?>
1788 <?php if ($img = Arr::get($link, 'icon_image')): ?>
1789 <div class="community_avatar">
1790 <img alt="" src="<?php echo esc_url($img); ?>"/>
1791 </div>
1792 <?php elseif ($emoji = Arr::get($link, 'emoji')): ?>
1793 <div class="community_avatar">
1794 <span class="fcom_emoji"><?php echo esc_html($emoji); ?></span>
1795 </div>
1796 <?php elseif ($svg = Arr::get($link, 'shape_svg')): ?>
1797 <div class="community_avatar">
1798 <span class="fcom_shape"><i
1799 class="el-icon"><?php echo \FluentCommunity\App\Services\CustomSanitizer::sanitizeSvg($svg); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></i></span>
1800 </div>
1801 <?php else:
1802 echo '<div class="community_avatar">' . wp_kses_post($fallback) . '</div>';
1803 endif;
1804 }
1805
1806 /**
1807 * Get the IP address of the user.
1808 *
1809 * @param bool $anonymize Whether to anonymize the IP address.
1810 * @return string The IP address.
1811 */
1812 public static function getIp($anonymize = false)
1813 {
1814 static $ipAddress;
1815
1816 if ($ipAddress) {
1817 return $ipAddress;
1818 }
1819
1820 if (empty($_SERVER['REMOTE_ADDR'])) {
1821 // It's a local cli request
1822 return '127.0.0.1';
1823 }
1824
1825 $ipAddress = '';
1826 if (isset($_SERVER["HTTP_CF_CONNECTING_IP"])) {
1827 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["REMOTE_ADDR"]));
1828 //If it's a valid Cloudflare request
1829 if (self::isCfIp($ipAddress)) {
1830 //Use the CF-Connecting-IP header.
1831 $ipAddress = sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']));
1832 }
1833 } else if ($_SERVER['REMOTE_ADDR'] == '127.0.0.1') {
1834 // most probably it's local reverse proxy
1835 if (isset($_SERVER["HTTP_CLIENT_IP"])) {
1836 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
1837 } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1838 $forwardedIp = trim(current(preg_split('/,/', sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR'])))));
1839 if (rest_is_ip_address($forwardedIp)) {
1840 $ipAddress = $forwardedIp;
1841 }
1842 }
1843 }
1844
1845 if (!$ipAddress) {
1846 $ipAddress = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR']));
1847 }
1848
1849 $ipAddress = preg_replace('/^(\d+\.\d+\.\d+\.\d+):\d+$/', '\1', $ipAddress);
1850
1851 $ipAddress = apply_filters('fluent_auth/user_ip', $ipAddress);
1852
1853 if ($anonymize) {
1854 return wp_privacy_anonymize_ip($ipAddress);
1855 }
1856
1857 return $ipAddress;
1858 }
1859
1860 /**
1861 * Check if the IP address is from Cloudflare.
1862 *
1863 * @param string $ip The IP address to check.
1864 * @return bool True if the IP is from Cloudflare, false otherwise.
1865 */
1866 public static function isCfIp($ip = '')
1867 {
1868 if (!$ip && isset($_SERVER["REMOTE_ADDR"])) {
1869 $ip = sanitize_text_field(wp_unslash($_SERVER["REMOTE_ADDR"]));
1870 }
1871
1872 if (!$ip) {
1873 return false;
1874 }
1875
1876 $cloudflareIPRanges = array(
1877 '173.245.48.0/20',
1878 '103.21.244.0/22',
1879 '103.22.200.0/22',
1880 '103.31.4.0/22',
1881 '141.101.64.0/18',
1882 '108.162.192.0/18',
1883 '190.93.240.0/20',
1884 '188.114.96.0/20',
1885 '197.234.240.0/22',
1886 '198.41.128.0/17',
1887 '162.158.0.0/15',
1888 '104.16.0.0/13',
1889 '104.24.0.0/14',
1890 '172.64.0.0/13',
1891 '131.0.72.0/22',
1892 );
1893
1894 //Make sure that the request came via Cloudflare.
1895 foreach ($cloudflareIPRanges as $range) {
1896 //Use the ip_in_range function from Joomla.
1897 if (self::ipInRange($ip, $range)) {
1898 //IP is valid. Belongs to Cloudflare.
1899 return true;
1900 }
1901 }
1902
1903 return false;
1904 }
1905
1906 /**
1907 * Check if the IP address is in the given range.
1908 *
1909 * @param string $ip The IP address to check.
1910 * @param string $range The range to check against.
1911 * @return bool True if the IP is in the range, false otherwise.
1912 */
1913 private static function ipInRange($ip, $range)
1914 {
1915 if (strpos($range, '/') !== false) {
1916 // $range is in IP/NETMASK format
1917 list($range, $netmask) = explode('/', $range, 2);
1918 if (strpos($netmask, '.') !== false) {
1919 // $netmask is a 255.255.0.0 format
1920 $netmask = str_replace('*', '0', $netmask);
1921 $netmask_dec = ip2long($netmask);
1922 return ((ip2long($ip) & $netmask_dec) == (ip2long($range) & $netmask_dec));
1923 } else {
1924 // $netmask is a CIDR size block
1925 // fix the range argument
1926 $x = explode('.', $range);
1927 while (count($x) < 4) $x[] = '0';
1928 list($a, $b, $c, $d) = $x;
1929 $range = sprintf("%u.%u.%u.%u", empty($a) ? '0' : $a, empty($b) ? '0' : $b, empty($c) ? '0' : $c, empty($d) ? '0' : $d);
1930 $range_dec = ip2long($range);
1931 $ip_dec = ip2long($ip);
1932
1933 # Strategy 1 - Create the netmask with 'netmask' 1s and then fill it to 32 with 0s
1934 #$netmask_dec = bindec(str_pad('', $netmask, '1') . str_pad('', 32-$netmask, '0'));
1935
1936 # Strategy 2 - Use math to create it
1937 $wildcard_dec = pow(2, (32 - $netmask)) - 1;
1938 $netmask_dec = ~$wildcard_dec;
1939
1940 return (($ip_dec & $netmask_dec) == ($range_dec & $netmask_dec));
1941 }
1942 } else {
1943 // range might be 255.255.*.* or 1.2.3.0-1.2.3.255
1944 if (strpos($range, '*') !== false) { // a.b.*.* format
1945 // Just convert to A-B format by setting * to 0 for A and 255 for B
1946 $lower = str_replace('*', '0', $range);
1947 $upper = str_replace('*', '255', $range);
1948 $range = "$lower-$upper";
1949 }
1950
1951 if (strpos($range, '-') !== false) { // A-B format
1952 list($lower, $upper) = explode('-', $range, 2);
1953 $lower_dec = (float)sprintf("%u", ip2long($lower));
1954 $upper_dec = (float)sprintf("%u", ip2long($upper));
1955 $ip_dec = (float)sprintf("%u", ip2long($ip));
1956 return (($ip_dec >= $lower_dec) && ($ip_dec <= $upper_dec));
1957 }
1958 return false;
1959 }
1960 }
1961
1962 public static function getPortalRequestPath($requestUri)
1963 {
1964 $portalSlug = self::getPortalSlug();
1965
1966 // If portal is mounted at site root with empty requestUri, ignore query-only requests that do not relate to the community portal.
1967 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing check on $_GET, no state mutation
1968 if ($portalSlug === '' && $requestUri === '' && !empty($_GET) && !self::hasSupportedQueryParam()) {
1969 return false;
1970 }
1971
1972 if ($portalSlug == $requestUri) {
1973 return 'portal_home';
1974 }
1975
1976 if (!$requestUri) {
1977 return false;
1978 }
1979
1980 if ($portalSlug) {
1981 // remove the portal slug from the request uri. Don't use str_replace as it will replace all occurrences
1982 $requestUri = substr($requestUri, strlen($portalSlug));
1983 }
1984
1985 $parts = explode('/', $requestUri);
1986 $start = $parts[0];
1987
1988 if (!$portalSlug && $start == 'fcom_route') {
1989 return $start;
1990 }
1991
1992 $routeStats = self::portalRoutePaths();
1993
1994 if (in_array($start, $routeStats)) {
1995 return $requestUri;
1996 }
1997
1998 return false;
1999 }
2000
2001 /**
2002 * Check if the current request has any supported query parameter.
2003 *
2004 * @return bool
2005 */
2006 private static function hasSupportedQueryParam()
2007 {
2008 $supportedParams = (array) apply_filters('fluent_community/portal_supported_query_params', [
2009 'customizer_panel',
2010 'create_space'
2011 ]);
2012
2013 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
2014 foreach (array_keys($_GET) as $key) {
2015 if (strpos($key, 'fcom_') === 0) {
2016 return true;
2017 }
2018 if (in_array($key, $supportedParams, true)) {
2019 return true;
2020 }
2021 }
2022 return false;
2023 }
2024
2025 public static function getTopicsConfig()
2026 {
2027 $config = Utility::getOption('topics_config', []);
2028 $default = [
2029 'max_topics_per_post' => 1,
2030 'max_topics_per_space' => 20,
2031 'show_on_post_card' => 'yes'
2032 ];
2033 return wp_parse_args($config, $default);
2034 }
2035
2036 public static function getModerationConfig()
2037 {
2038 $config = Utility::getOption('moderation_config', []);
2039
2040 $default = [
2041 'is_enabled' => 'no',
2042 'profanity_filter' => "",
2043 'flag_after_threshold' => 0,
2044 'flag_all_new_posts' => 'no',
2045 'first_post_approval' => 'no',
2046 'flag_all_new_posts_spaces' => [],
2047 ];
2048
2049 return wp_parse_args($config, $default);
2050 }
2051
2052 public static function getReportReasons()
2053 {
2054 return apply_filters('fluent_community/report_reasons', [
2055 'harassment' => __('Harassment', 'fluent-community'),
2056 'spam' => __('Spam', 'fluent-community'),
2057 'offensive' => __('Offensive', 'fluent-community'),
2058 'incorrect_space' => __('Incorrect Space', 'fluent-community'),
2059 'against_community' => __('Against Community Rules', 'fluent-community'),
2060 'other' => __('Other', 'fluent-community'),
2061 ]);
2062 }
2063
2064 public static function htmlToMd($html)
2065 {
2066 return preg_replace('/<a.*?href="(.*?)".*?>(.*?)<\/a>/', '[$2]($1)', $html);
2067 }
2068
2069 public static function isProfanity($profanity, $text)
2070 {
2071 $profanity = explode(',', $profanity);
2072 if (empty($profanity)) {
2073 return false;
2074 }
2075 $profanity = array_map('trim', $profanity);
2076 $profanity = array_map(function ($word) {
2077 return mb_strtolower($word, 'UTF-8');
2078 }, $profanity);
2079 $text = mb_strtolower($text, 'UTF-8');
2080
2081 // Convert words into a regex pattern (ensuring whole-word matching)
2082 $pattern = '/(?<!\p{L})(' . implode('|', array_map('preg_quote', $profanity)) . ')(?!\p{L})/iu';
2083
2084 if (preg_match($pattern, $text, $matches)) {
2085 return $matches[0];
2086 }
2087
2088 return false;
2089 }
2090
2091 public static function getFullDayName($day)
2092 {
2093 $dayMap = [
2094 'sun' => 'sunday',
2095 'mon' => 'monday',
2096 'tue' => 'tuesday',
2097 'wed' => 'wednesday',
2098 'thu' => 'thursday',
2099 'fri' => 'friday',
2100 'sat' => 'saturday'
2101 ];
2102
2103 return isset($dayMap[$day]) ? $dayMap[$day] : $day . 'day';
2104 }
2105
2106 public static function getPostOrderOptions($context = 'feed')
2107 {
2108 $options = [
2109 'new_activity' => __('New Activity', 'fluent-community'),
2110 'latest' => __('Latest', 'fluent-community'),
2111 'oldest' => __('Oldest', 'fluent-community'),
2112 'popular' => __('Popular', 'fluent-community'),
2113 'likes' => __('Likes', 'fluent-community'),
2114 'alphabetical' => __('Alphabetical', 'fluent-community'),
2115 'unanswered' => __('Unanswered', 'fluent-community'),
2116 ];
2117
2118 return apply_filters('fluent_community/post_order_options', $options, $context);
2119 }
2120
2121 public static function getCommentOrderOptions($context = 'comment')
2122 {
2123 $options = [
2124 'oldest' => __('Earliest', 'fluent-community'),
2125 'latest' => __('Latest', 'fluent-community'),
2126 'popular' => __('Popular', 'fluent-community'),
2127 'most_replied' => __('Most Replied', 'fluent-community'),
2128 ];
2129
2130 return apply_filters('fluent_community/comment_order_options', $options, $context);
2131 }
2132
2133 public static function convertPhpDateToDayJSFormay($phpFormat)
2134 {
2135 // Mapping PHP date format characters to Day.js format characters
2136 $replacements = [
2137 // Day
2138 'd' => 'DD', // Day of the month, 2 digits with leading zeros
2139 'D' => 'ddd', // A textual representation of a day, three letters
2140 'j' => 'D', // Day of the month without leading zeros
2141 'l' => 'dddd', // A full textual representation of the day of the week
2142 'N' => 'E', // ISO-8601 numeric representation of the day of the week
2143 'S' => 'o', // English ordinal suffix for the day of the month, 2 characters
2144 'w' => 'd', // Numeric representation of the day of the week
2145 'z' => 'DDD', // The day of the year (starting from 0)
2146
2147 // Week
2148 'W' => 'W', // ISO-8601 week number of year, weeks starting on Monday
2149
2150 // Month
2151 'F' => 'MMMM', // A full textual representation of a month
2152 'm' => 'MM', // Numeric representation of a month, with leading zeros
2153 'M' => 'MMM', // A short textual representation of a month, three letters
2154 'n' => 'M', // Numeric representation of a month, without leading zeros
2155 't' => '', // Not supported in Day.js (Number of days in the given month)
2156
2157 // Year
2158 'L' => '', // Not supported in Day.js (Whether it's a leap year)
2159 'o' => 'GGGG', // ISO-8601 week-numbering year
2160 'Y' => 'YYYY', // A full numeric representation of a year, 4 digits
2161 'y' => 'YY', // A two digit representation of a year
2162
2163 // Time
2164 'a' => 'a', // Lowercase Ante meridiem and Post meridiem
2165 'A' => 'A', // Uppercase Ante meridiem and Post meridiem
2166 'B' => '', // Not supported in Day.js (Swatch Internet time)
2167 'g' => 'h', // 12-hour format of an hour without leading zeros
2168 'G' => 'H', // 24-hour format of an hour without leading zeros
2169 'h' => 'hh', // 12-hour format of an hour with leading zeros
2170 'H' => 'HH', // 24-hour format of an hour with leading zeros
2171 'i' => 'mm', // Minutes with leading zeros
2172 's' => 'ss', // Seconds with leading zeros
2173 'u' => 'SSS', // Milliseconds (Day.js uses SSS for fractional seconds)
2174 'v' => 'SSS', // Milliseconds (Day.js uses SSS for fractional seconds)
2175
2176 // Timezone
2177 'e' => '', // Not supported in Day.js (Timezone identifier)
2178 'I' => '', // Not supported in Day.js (Whether or not the date is in daylight saving time)
2179 'O' => 'ZZ', // Difference to Greenwich time (GMT) in hours
2180 'P' => 'Z', // Difference to Greenwich time (GMT) with colon between hours and minutes
2181 'T' => '', // Not supported in Day.js (Timezone abbreviation)
2182 'Z' => '', // Not supported in Day.js (Timezone offset in seconds)
2183
2184 // Full Date/Time
2185 'c' => 'YYYY-MM-DDTHH:mm:ssZ', // ISO 8601 date
2186 'r' => 'ddd, DD MMM YYYY HH:mm:ss ZZ', // RFC 2822 formatted date
2187 'U' => 'X', // Seconds since the Unix Epoch (January 1 1970 00:00:00 GMT)
2188 ];
2189
2190 // Replace each PHP date format character with Day.js equivalent
2191 $dayjsFormat = "";
2192
2193 for ($i = 0; $i < strlen($phpFormat); $i++) {
2194 $char = $phpFormat[$i];
2195
2196 // Special handling for G\hi pattern
2197 if ($char === 'G' && $i + 2 < strlen($phpFormat) &&
2198 $phpFormat[$i + 1] === '\\' && $phpFormat[$i + 2] === 'h') {
2199 $dayjsFormat .= 'H[h]';
2200 $i += 2;
2201 continue;
2202 }
2203
2204 // Check if the character is escaped
2205 if ($char === "\\") {
2206 // Add the next character to the result as is, without mapping
2207 $i++;
2208 if ($i < strlen($phpFormat)) {
2209 $dayjsFormat .= "\\" . $phpFormat[$i];
2210 }
2211 continue;
2212 }
2213
2214 // Add the mapped character or the character itself if not found in the mapping
2215 $dayjsFormat .= $replacements[$char] ?? $char;
2216 }
2217
2218 return $dayjsFormat;
2219 }
2220
2221 public static function getDateFormatter($isDayJs = false)
2222 {
2223 $format = get_option('date_format');
2224 if ($isDayJs) {
2225 return self::convertPhpDateToDayJSFormay($format);
2226 }
2227
2228 return $format;
2229 }
2230
2231 public static function getTimeFormatter($isDayJs = false)
2232 {
2233 $format = get_option('time_format');
2234
2235 if ($isDayJs) {
2236 return self::convertPhpDateToDayJSFormay($format);
2237 }
2238
2239 return $format;
2240 }
2241
2242 public static function normalizeToAscii($text)
2243 {
2244 if (function_exists('transliterator_transliterate')) {
2245 $result = transliterator_transliterate('Any-Latin; Latin-ASCII', $text);
2246 if ($result !== false) {
2247 return $result;
2248 }
2249 }
2250
2251 if (function_exists('iconv')) {
2252 $result = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text);
2253 if ($result !== false) {
2254 return $result;
2255 }
2256 }
2257
2258 return $text;
2259 }
2260 }
2261