PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
← All changes | app/Services/Helper.php +706 -177 1.10.5 → 2.4.5 View file →
@@ -3,8 +3,9 @@
3 3 namespace FluentSupport\App\Services;
4 4
5 5 use FluentSupport\App\App;
6 6 use FluentSupport\App\Models\Agent;
7 +use FluentSupport\App\Models\Attachment;
7 8 use FluentSupport\App\Models\Ticket;
8 9 use FluentSupport\App\Models\Conversation;
9 10 use FluentSupport\App\Models\Customer;
10 11 use FluentSupport\App\Models\MailBox;
@@ -15,17 +16,8 @@
15 16 use FluentSupport\App\Services\Includes\UploadService;
16 17 use FluentSupport\App\Services\EmailNotification\Settings;
17 18 use FluentSupport\Framework\Support\Arr;
18 19
19 -/**
20 - * Helper - REST API Helper Class
21 - *
22 - * App helper for REST API
23 - *
24 - * @package FluentSupport\App\Services
25 - *
26 - * @version 1.0.0
27 - */
28 20 class Helper
29 21 {
30 22 public static function FluentSupport($module = null)
31 23 {
@@ -32,12 +24,12 @@
32 24 return App::getInstance($module);
33 25 }
34 26
35 27 /**
36 - * Get agent information by user id
37 - * The function will get user id as parameter or get id from session and return agent information
38 - * @param null $userId
39 - * @return false | Agent
28 + * Agent for the given user, or for the current user when $userId is null.
29 + *
30 + * @param int|null $userId
31 + * @return false|Agent|null
40 32 */
41 33 public static function getAgentByUserId($userId = null)
42 34 {
43 35 if ($userId === null) {
@@ -49,10 +41,8 @@
49 41 return Agent::where('user_id', $userId)->first();
50 42 }
51 43
52 44 /**
53 - * This function will return the list of ticket priorities list for customer
54 - *
55 45 * @return mixed
56 46 */
57 47 public static function customerTicketPriorities()
58 48 {
@@ -63,10 +53,8 @@
63 53 ]);
64 54 }
65 55
66 56 /**
67 - * This function will return the list of ticket priorities list for Admin
68 - *
69 57 * @return mixed
70 58 */
71 59 public static function adminTicketPriorities()
72 60 {
@@ -78,10 +66,8 @@
78 66 }
79 67
80 68
81 69 /**
82 - * This function will return ticket status group
83 - *
84 70 * @return mixed
85 71 */
86 72 public static function ticketStatusGroups()
87 73 {
@@ -94,10 +80,8 @@
94 80 ]);
95 81 }
96 82
97 83 /**
98 - * This function will return custom ticket status group
99 - *
100 84 * @return mixed
101 85 */
102 86 public static function changeableTicketStatuses()
103 87 {
@@ -109,10 +93,8 @@
109 93 return apply_filters('fluent_support/changeable_ticket_statuses', $ticketStatus);
110 94 }
111 95
112 96 /**
113 - * This function will return ticket status list
114 - *
115 97 * @return mixed
116 98 */
117 99 public static function ticketStatuses()
118 100 {
@@ -229,17 +211,14 @@
229 211 ]);
230 212 }
231 213
232 214 /**
233 - * getOption method will return settings using key
234 - * This method will get key as parameter, fetch data from database, beautify the data and return
235 - * @param $key
236 - * @param string $default
215 + * @param string $key
216 + * @param mixed $default Returned when the option is missing or empty.
237 217 * @return mixed|string
238 218 */
239 219 public static function getOption($key, $default = '')
240 220 {
241 - //Get settings from meta table using the key
242 221 $data = Meta::where('object_type', 'option')
243 222 ->where('key', $key)
244 223 ->first();
245 224
@@ -253,22 +232,20 @@
253 232 return $default;
254 233 }
255 234
256 235 /**
257 - * updateOption method will update or insert settings
258 - * This method will get key and value as parameter, check exists or not. If exist update value by key, else insert value for the key
259 - * @param $key
260 - * @param $value
236 + * Upsert an option row in the meta table.
237 + *
238 + * @param string $key
239 + * @param mixed $value
261 240 * @return mixed
262 241 */
263 242 public static function updateOption($key, $value)
264 243 {
265 - //Get settings from meta table using the key
266 244 $data = Meta::where('object_type', 'option')
267 245 ->where('key', $key)
268 246 ->first();
269 247
270 - //If data is available, update existing data and return
271 248 if ($data) {
272 249 return Meta::where('id', $data->id)
273 250 ->update([
274 251 'value' => maybe_serialize($value)
@@ -274,9 +251,8 @@
274 251 'value' => maybe_serialize($value)
275 252 ]);
276 253 }
277 254
278 - //If newly submit, create new record and return
279 255 return Meta::insert([
280 256 'object_type' => 'option',
281 257 'key' => $key,
282 258 'value' => maybe_serialize($value)
@@ -282,8 +258,9 @@
282 258 'value' => maybe_serialize($value)
283 259 ]);
284 260 }
285 261
262 + /** @internal Not called from core controllers — available for Pro/hook usage. */
286 263 public static function deleteOption($key)
287 264 {
288 265 return Meta::where('object_type', 'option')
289 266 ->where('key', $key)
@@ -290,10 +267,9 @@
290 267 ->delete();
291 268 }
292 269
293 270 /**
294 - * getIntegrationOption method will return the integration settings by integration key
295 - * @param $key
271 + * @param string $key
296 272 * @param string $default
297 273 * @return mixed|string
298 274 */
299 275 public static function getIntegrationOption($key, $default = '')
@@ -312,11 +288,12 @@
312 288 return $default;
313 289 }
314 290
315 291 /**
316 - * updateIntegrationOption method will update existing settings or create new settings by integration key
317 - * @param $key
318 - * @param $value
292 + * Upsert an integration settings row.
293 + *
294 + * @param string $key
295 + * @param mixed $value
319 296 * @return mixed
320 297 */
321 298 public static function updateIntegrationOption($key, $value)
322 299 {
@@ -340,9 +317,10 @@
340 317
341 318 public static function getTicketViewUrl($ticket)
342 319 {
343 320 $baseUrl = self::getPortalBaseUrl();
344 - return $baseUrl . '/#/ticket/' . $ticket->id . '/view';
321 + $ticketNumber = $ticket->serial_number ?? $ticket->id;
322 + return $baseUrl . '/#/ticket/' . $ticketNumber . '/view';
345 323 }
346 324
347 325 public static function getTicketViewSignedUrl($ticket)
348 326 {
@@ -350,16 +328,17 @@
350 328 return self::getTicketViewUrl($ticket);
351 329 }
352 330
353 331 $baseUrl = self::getPortalBaseUrl();
332 + $ticketNumber = $ticket->serial_number ?? $ticket->id;
354 333
355 334 $baseUrl = add_query_arg([
356 335 'fs_view' => 'ticket',
357 336 'support_hash' => $ticket->hash,
358 - 'ticket_id' => $ticket->id,
337 + 'ticket_id' => $ticketNumber,
359 338 ], $baseUrl);
360 339
361 - return $baseUrl . '#/ticket/' . $ticket->id . '/view';
340 + return $baseUrl . '#/ticket/' . $ticketNumber . '/view';
362 341 }
363 342
364 343 public static function saveOpenAIData($objectType, $key, $data)
365 344 {
@@ -380,8 +359,98 @@
380 359 }
381 360
382 361 }
383 362
363 + public static function saveAIProviderSettings(array $data)
364 + {
365 + if (!empty($data['api_key'])) {
366 + $data['api_key'] = static::encryptApiKey($data['api_key']);
367 + }
368 +
369 + $serializedData = maybe_serialize($data);
370 + $previous = Meta::where('object_type', '_fs_ai_provider_settings')->first();
371 +
372 + if ($previous) {
373 + return Meta::where('object_type', '_fs_ai_provider_settings')->update([
374 + 'value' => $serializedData,
375 + ]);
376 + }
377 +
378 + return Meta::insert([
379 + 'object_type' => '_fs_ai_provider_settings',
380 + 'key' => '_fs_ai_provider_data',
381 + 'value' => $serializedData,
382 + ]);
383 + }
384 +
385 + public static function getAIProviderSettings(): array
386 + {
387 + $record = Meta::where('object_type', '_fs_ai_provider_settings')->first();
388 +
389 + if (!$record) {
390 + $record = Meta::where('object_type', '_fs_openai_settings')->first();
391 + }
392 +
393 + if (!$record) {
394 + return [];
395 + }
396 +
397 + $settings = static::safeUnserialize($record->value);
398 +
399 + if (empty($settings) || !is_array($settings)) {
400 + return [];
401 + }
402 +
403 + if (!isset($settings['provider'])) {
404 + $settings['provider'] = 'openai';
405 + }
406 +
407 + if (!empty($settings['api_key'])) {
408 + $settings['api_key'] = static::decryptApiKey($settings['api_key']);
409 + }
410 +
411 + return $settings;
412 + }
413 +
414 + public static function encryptApiKey(string $apiKey): string
415 + {
416 + if (!extension_loaded('openssl')) {
417 + return $apiKey;
418 + }
419 +
420 + $cipher = 'AES-256-CBC';
421 + $key = substr(hash('sha256', AUTH_KEY . SECURE_AUTH_KEY, true), 0, 32);
422 + $iv = openssl_random_pseudo_bytes(openssl_cipher_iv_length($cipher));
423 + $encrypted = openssl_encrypt($apiKey, $cipher, $key, 0, $iv);
424 +
425 + if ($encrypted === false) {
426 + return $apiKey;
427 + }
428 +
429 + return 'fsai:' . base64_encode($iv . $encrypted);
430 + }
431 +
432 + public static function decryptApiKey(string $value): string
433 + {
434 + if (!extension_loaded('openssl') || strncmp($value, 'fsai:', 5) !== 0) {
435 + return $value;
436 + }
437 +
438 + $cipher = 'AES-256-CBC';
439 + $decoded = base64_decode(substr($value, 5));
440 + $ivLen = openssl_cipher_iv_length($cipher);
441 +
442 + if (strlen($decoded) <= $ivLen) {
443 + return $value;
444 + }
445 +
446 + $key = substr(hash('sha256', AUTH_KEY . SECURE_AUTH_KEY, true), 0, 32);
447 + $iv = substr($decoded, 0, $ivLen);
448 + $decrypted = openssl_decrypt(substr($decoded, $ivLen), $cipher, $key, 0, $iv);
449 +
450 + return $decrypted !== false ? $decrypted : $value;
451 + }
452 +
384 453 public static function authorizeChatGPTAPIKey($data)
385 454 {
386 455 return wp_remote_get('https://api.openai.com/v1/models', [
387 456 'headers' => [
@@ -404,44 +473,144 @@
404 473 return $baseUrl . 'tickets/' . $ticket->id . '/view';
405 474 }
406 475
407 476 /**
408 - * getPortalBaseUrl will get the portal page id and return link of the page
409 477 * @return mixed
410 478 */
411 479 public static function getPortalBaseUrl()
412 480 {
413 481 $businessSettings = self::getBusinessSettings();
414 - $baseUrl = get_permalink($businessSettings['portal_page_id']);
415 - $baseUrl = rtrim($baseUrl, '/\\');
416 - return apply_filters('fluent_support/portal_base_url', $baseUrl);
482 + $portalType = Arr::get($businessSettings, 'ticket_link_portal', 'default');
483 + $baseUrl = null;
484 +
485 + if (self::isPortalActive($portalType)) {
486 + if ($portalType === 'woocommerce' && function_exists('wc_get_endpoint_url') && function_exists('wc_get_page_permalink')) {
487 + $accountUrl = wc_get_page_permalink('myaccount');
488 + if ($accountUrl && $accountUrl !== '#') {
489 + $baseUrl = wc_get_endpoint_url('support-tickets', '', $accountUrl);
490 + }
491 + } elseif ($portalType === 'fluent_cart') {
492 + $baseUrl = \FluentCart\App\Services\URL::getCustomerDashboardUrl('fluent-support') ?: null;
493 + } elseif ($portalType === 'fluent_community') {
494 + $baseUrl = \FluentCommunity\App\Services\Helper::baseUrl('support/') ?: null;
495 + }
496 + }
497 +
498 + if (!$baseUrl) {
499 + $baseUrl = get_permalink(absint(Arr::get($businessSettings, 'portal_page_id')));
500 + }
501 +
502 + return apply_filters('fluent_support/portal_base_url', rtrim((string) $baseUrl, '/\\'));
417 503 }
418 504
505 + public static function isPortalActive($portalType)
506 + {
507 + if ($portalType === 'woocommerce') {
508 + return defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') && defined('WC_PLUGIN_FILE');
509 + }
510 +
511 + if ($portalType === 'fluent_cart') {
512 + return defined('FLUENTCART_VERSION');
513 + }
514 +
515 + if ($portalType === 'fluent_community') {
516 + return defined('FLUENT_COMMUNITY_PLUGIN_VERSION');
517 + }
518 +
519 + return false;
520 + }
521 +
419 522 public static function getPortalAdminBaseUrl()
420 523 {
421 524 return apply_filters('fluent_support/portal_admin_base_url', admin_url('admin.php?page=fluent-support/#/'));
422 525 }
423 526
424 - public static function getBusinessSettings($key = null)
527 + /**
528 + * Global business settings, or one of them. $default applies only to a key
529 + * missing from the stored settings and their built-in defaults.
530 + *
531 + * @param string|null $key
532 + * @param mixed $default
533 + * @return mixed
534 + */
535 + public static function getBusinessSettings($key = null, $default = null)
425 536 {
426 537 static $settings;
427 538
428 - if ($settings && $key) {
429 - return Arr::get($settings, $key);
539 + if (!$settings) {
540 + $settings = (new Settings())->globalBusinessSettings();
430 541 }
431 542
432 - if ($settings) {
433 - return $settings;
543 + if ($key) {
544 + return Arr::get($settings, $key, $default);
434 545 }
435 546
436 - $settings = (new Settings())->globalBusinessSettings();
547 + return $settings;
548 + }
437 549
438 - if ($key) {
439 - return Arr::get($settings, $key);
550 + /**
551 + * Fields the "Custom Registration Form Field" setting can select. Their
552 + * keys are written to user meta, so the stored setting is limited to these.
553 + *
554 + * @return array key => label
555 + */
556 + public static function getCustomRegistrationFieldOptions()
557 + {
558 + return [
559 + 'address_line_1' => 'Address Line 1',
560 + 'address_line_2' => 'Address Line 2',
561 + 'city' => 'City',
562 + 'zip' => 'Zip Code',
563 + 'state' => 'State',
564 + 'country' => 'Country',
565 + ];
566 + }
567 +
568 + /**
569 + * Registration field keys that may be written to user meta. Keys outside
570 + * the setting's options are dropped before the filter runs, and keys
571 + * WordPress treats as authority are dropped even when a filter adds them.
572 + *
573 + * @return array
574 + */
575 + public static function getCustomRegistrationFieldKeys()
576 + {
577 + $keys = array_intersect(
578 + (array) self::getBusinessSettings('custom_registration_form_field'),
579 + array_keys(self::getCustomRegistrationFieldOptions())
580 + );
581 +
582 + $keys = apply_filters('fluent_support/custom_registration_form_fields_key', array_values($keys));
583 +
584 + if (!is_array($keys)) {
585 + return [];
440 586 }
441 - return $settings;
587 +
588 + return array_values(array_filter($keys, function ($key) {
589 + return is_string($key) && $key !== '' && !self::isProtectedUserMetaKey($key);
590 + }));
442 591 }
443 592
593 + /**
594 + * Whether a user meta key holds roles, capabilities, sessions or plugin
595 + * permissions. meta_key compares case-insensitively and ignores trailing
596 + * spaces in MySQL, so the check does too.
597 + *
598 + * @param string $key
599 + * @return bool
600 + */
601 + public static function isProtectedUserMetaKey($key)
602 + {
603 + $key = strtolower(trim((string) $key));
604 +
605 + if (in_array($key, ['session_tokens', strtolower(\FluentSupport\App\Modules\PermissionManager::META_KEY)], true)) {
606 + return true;
607 + }
608 +
609 + // {prefix}capabilities and {prefix}user_level, for every site prefix.
610 + return (bool) preg_match('/(capabilities|user_level)$/', $key);
611 + }
612 +
444 613 public static function isAgentFeedbackEnabled()
445 614 {
446 615 return self::getBusinessSettings('agent_feedback_rating', 'no') == 'yes';
447 616 }
@@ -516,11 +685,8 @@
516 685 }
517 686
518 687 public static function getCurrentAgent()
519 688 {
520 - // If user is logged in then return the agent by user id.
521 - // This `get_current_user_id` function is WP function and
522 - // it returns user id if user is logged in.
523 689 if (get_current_user_id()) {
524 690 return Agent::where('user_id', get_current_user_id())->first();
525 691 }
526 692 }
@@ -526,21 +692,19 @@
526 692 }
527 693
528 694 public static function getCurrentCustomer()
529 695 {
530 - // If user is logged in then return the customer by user id.
531 - // This `get_current_user_id` function is WP function and
532 - // it returns user id if user is logged in.
533 - if (get_current_user_id()) { //if user is logged in
534 - return Customer::where('user_id', get_current_user_id())->first();
696 + if (get_current_user_id()) {
697 + // user_id is not unique, so order by id to pick the same record as
698 + // getCurrentPerson() and Customer::getCustomerFromData().
699 + return Customer::where('user_id', get_current_user_id())
700 + ->orderBy('id', 'ASC')
701 + ->first();
535 702 }
536 703 }
537 704
538 705 public static function getCurrentPerson()
539 706 {
540 - // If user is logged in then return the person(agent/customer) by user id.
541 - // This `get_current_user_id` function is WP function and
542 - // it returns user id if user is logged in.
543 707 if (get_current_user_id()) {
544 708 return Person::where('user_id', get_current_user_id())
545 709 ->orderBy('id', 'ASC')
546 710 ->first();
@@ -552,13 +716,14 @@
552 716 {
553 717 return Customer::where('id', $customerid)->first();
554 718 }
555 719
720 + /** @internal Not called from core controllers — available for Pro/hook usage. */
556 721 public static function sanitizeOrderValue($orderType = '')
557 722 {
558 723 $orderBys = ['ASC', 'DESC'];
559 724
560 - $orderType = trim(strtoupper($orderType));
725 + $orderType = trim(strtoupper((string)($orderType ?? '')));
561 726
562 727 return in_array($orderType, $orderBys) ? $orderType : 'DESC';
563 728 }
564 729
@@ -599,10 +764,9 @@
599 764 return $crmConfigs;
600 765 }
601 766
602 767 /**
603 - * getFluentCrmContactData method will get information from fluent crm using user email
604 - * @param $customer
768 + * @param Customer $customer
605 769 * @return array|false
606 770 */
607 771 public static function getFluentCrmContactData($customer)
608 772 {
@@ -608,9 +772,8 @@
608 772 {
609 773 if (!defined('FLUENTCRM')) {
610 774 return false;
611 775 }
612 - //Get contact info from FluentCRM using customer email
613 776 $contact = \FluentCrmApi('contacts')->getContactByUserRef($customer->email);
614 777 if ($contact) {
615 778 $tags = $contact->tags;
616 779 $lists = $contact->lists;
@@ -616,9 +779,8 @@
616 779 $lists = $contact->lists;
617 780 $urlBase = apply_filters('fluentcrm_menu_url_base', admin_url('admin.php?page=fluentcrm-admin#/'));
618 781 $crmProfileUrl = $urlBase . 'subscribers/' . $contact->id;
619 782
620 - //Return contact data
621 783 return [
622 784 'id' => $contact->id,
623 785 'first_name' => $contact->first_name,
624 786 'last_name' => $contact->last_name,
@@ -634,26 +796,31 @@
634 796
635 797 return false;
636 798 }
637 799
638 - public static function openAIIntegrationStatus() {
639 - $chatGPTSettingsData = Meta::where('object_type', '_fs_openai_settings')->value('value');
800 + public static function openAIIntegrationStatus()
801 + {
802 + $settings = static::getAIProviderSettings();
640 803
641 - if ($chatGPTSettingsData) {
642 - $settings = static::safeUnserialize($chatGPTSettingsData);
643 - return !empty($settings['api_key']);
804 + if (($settings['enabled'] ?? 'yes') === 'no') {
805 + return false;
644 806 }
645 807
646 - return false;
808 + return !empty($settings['api_key']);
647 809 }
648 810
649 811 public static function fluentBotIntegrationStatus()
650 812 {
651 - $settings = static::safeUnserialize(
652 - Meta::where('object_type', 'fluent_bot_settings')->value('value')
653 - );
813 + // saveFluentBotSettings() doesn't prune old rows, so read the newest one.
814 + $meta = Meta::where([
815 + 'object_type' => 'fluent_bot_settings',
816 + 'object_id' => 1,
817 + 'key' => '_fs_fluent_bot_config'
818 + ])->orderByDesc('id')->first();
654 819
655 - return !empty($settings['isEnabled']) && $settings['isEnabled'] === 'true';
820 + $settings = static::safeUnserialize($meta ? $meta->value : null);
821 +
822 + return isset($settings['isEnabled']) && filter_var($settings['isEnabled'], FILTER_VALIDATE_BOOLEAN);
656 823 }
657 824
658 825
659 826 public static function showTicketSummaryAdminBar()
@@ -668,8 +835,12 @@
668 835 }
669 836
670 837 public static function generateMessageID($email)
671 838 {
839 + if (!$email || !is_string($email)) {
840 + return false;
841 + }
842 +
672 843 $emailParts = explode('@', $email);
673 844 if (count($emailParts) != 2) {
674 845 return false;
675 846 }
@@ -713,9 +884,9 @@
713 884 public static function getAuthProvider()
714 885 {
715 886 if (defined('FLUENT_AUTH_PLUGIN_PATH')) {
716 887 $settings = \FluentAuth\App\Helpers\Helper::getAuthFormsSettings();
717 - if ($settings['enabled'] == 'yes') {
888 + if ($settings['enabled'] == 'yes' || FluentAuthBridge::shouldAdopt()) {
718 889 return 'fluent_auth';
719 890 }
720 891 }
721 892
@@ -721,12 +892,29 @@
721 892
722 893 return 'fluent_support';
723 894 }
724 895
896 + /**
897 + * Whether visitors may create an account, per WordPress's own registration setting.
898 + *
899 + * @return bool
900 + */
901 + public static function isSignupEnabled()
902 + {
903 + if (is_multisite()) {
904 + return in_array(get_site_option('registration'), ['user', 'all'], true);
905 + }
906 +
907 + return (bool)get_option('users_can_register');
908 + }
909 +
910 + /** @internal Not called from core controllers — available for Pro/hook usage. */
725 911 public static function getDriversKey(){
726 912 return [
727 913 'dropbox_settings',
728 914 'google_drive_settings',
915 + 'cloudflare_r2_settings',
916 + 'amazon_s3_settings',
729 917 'local'
730 918 ];
731 919 }
732 920
@@ -742,11 +930,9 @@
742 930 if ($driver) {
743 931 return $driver;
744 932 }
745 933
746 - // Now guess the driver and save it
747 -
748 - // check if dropbox is enabled
934 + // No driver saved yet: use the first enabled one and remember it.
749 935 $dropboxSettings = self::getIntegrationOption('dropbox_settings', null);
750 936 if ($dropboxSettings) {
751 937 $dropBoxEnabled = Meta::where('object_type', 'enabled_upload_drivers')
752 938 ->where('key', 'dropbox_settings')
@@ -759,9 +945,8 @@
759 945 return $driver;
760 946 }
761 947 }
762 948
763 - // check if google drive is enabled
764 949 $googleDriveSettings = self::getIntegrationOption('google_drive_settings', null);
765 950
766 951 if ($googleDriveSettings) {
767 952 $googleDriveEnabled = Meta::where('object_type', 'enabled_upload_drivers')
@@ -775,8 +960,38 @@
775 960 return $driver;
776 961 }
777 962 }
778 963
964 + $cloudflareR2Settings = self::getIntegrationOption('cloudflare_r2_settings', null);
965 +
966 + if ($cloudflareR2Settings) {
967 + $cloudflareR2Enabled = Meta::where('object_type', 'enabled_upload_drivers')
968 + ->where('key', 'cloudflare_r2_settings')
969 + ->where('value', 'yes')
970 + ->first();
971 +
972 + if ($cloudflareR2Enabled) {
973 + $driver = 'cloudflare_r2';
974 + self::updateOption('file_upload_driver', $driver);
975 + return $driver;
976 + }
977 + }
978 +
979 + $amazonS3Settings = self::getIntegrationOption('amazon_s3_settings', null);
980 +
981 + if ($amazonS3Settings) {
982 + $amazonS3Enabled = Meta::where('object_type', 'enabled_upload_drivers')
983 + ->where('key', 'amazon_s3_settings')
984 + ->where('value', 'yes')
985 + ->first();
986 +
987 + if ($amazonS3Enabled) {
988 + $driver = 'amazon_s3';
989 + self::updateOption('file_upload_driver', $driver);
990 + return $driver;
991 + }
992 + }
993 +
779 994 self::updateOption('file_upload_driver', 'local');
780 995 return 'local';
781 996 }
782 997
@@ -787,16 +1002,16 @@
787 1002 'title' => __('WooCommerce', 'fluent-support'),
788 1003 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/woocommerce.png',
789 1004 'is_integrated' => defined('WC_PLUGIN_FILE'),
790 1005 'description' => __('The most popular e-commerce platform for WordPress', 'fluent-support'),
791 - 'doc_url' => 'https://fluentsupport.com/docs/woocommerce-integration/',
1006 + 'doc_url' => 'https://docs.fluentsupport.com/woocommerce-integration',
792 1007 ],
793 1008 'fluent-cart' => [
794 - 'title' => __('Fluent Cart', 'fluent-support'),
1009 + 'title' => __('FluentCart', 'fluent-support'),
795 1010 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-cart.webp',
796 1011 'is_integrated' => defined('FLUENTCART_VERSION'),
797 1012 'description' => __('A New Era of eCommerce with WordPress', 'fluent-support'),
798 - 'doc_url' => 'https://fluentsupport.com/docs/fluentcart-integration/',
1013 + 'doc_url' => 'https://docs.fluentsupport.com/fluentcart-integration',
799 1014 ],
800 1015 'lifter-lms' => [
801 1016 'title' => __('LifterLMS', 'fluent-support'),
802 1017 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/lifter-lms.png',
@@ -801,9 +1016,9 @@
801 1016 'title' => __('LifterLMS', 'fluent-support'),
802 1017 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/lifter-lms.png',
803 1018 'is_integrated' => defined('LLMS_PLUGIN_FILE'),
804 1019 'description' => __('Course and e-learning platform built for WordPress', 'fluent-support'),
805 - 'doc_url' => 'https://fluentsupport.com/docs/lifterlms-integration/',
1020 + 'doc_url' => 'https://docs.fluentsupport.com/lifterlms-integration',
806 1021 ],
807 1022 'slack' => [
808 1023 'title' => __('Slack', 'fluent-support'),
809 1024 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/slack.png',
@@ -808,9 +1023,9 @@
808 1023 'title' => __('Slack', 'fluent-support'),
809 1024 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/slack.png',
810 1025 'is_integrated' => self::getFSIntegrationStatus('slack_settings'),
811 1026 'description' => __('Business communication platform designed to scale', 'fluent-support'),
812 - 'doc_url' => 'https://fluentsupport.com/docs/managing-tickets-using-slack/',
1027 + 'doc_url' => 'https://docs.fluentsupport.com/managing-tickets-using-slack',
813 1028 ],
814 1029 'pm-pro' => [
815 1030 'title' => __('Paid Memberships Pro', 'fluent-support'),
816 1031 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/pmpro.png',
@@ -815,9 +1030,9 @@
815 1030 'title' => __('Paid Memberships Pro', 'fluent-support'),
816 1031 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/pmpro.png',
817 1032 'is_integrated' => defined('PMPRO_VERSION'),
818 1033 'description' => __('The ultimate platform for any member-focused business', 'fluent-support'),
819 - 'doc_url' => 'https://fluentsupport.com/docs/paid-membership-pro-integration/',
1034 + 'doc_url' => 'https://docs.fluentsupport.com/paid-membership-pro-integration',
820 1035 ],
821 1036 'tutor-lms' => [
822 1037 'title' => __('Tutor LMS', 'fluent-support'),
823 1038 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/tutor-lms.png',
@@ -822,9 +1037,9 @@
822 1037 'title' => __('Tutor LMS', 'fluent-support'),
823 1038 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/tutor-lms.png',
824 1039 'is_integrated' => defined('TUTOR_VERSION'),
825 1040 'description' => __('Course and e-learning platform built for WordPress', 'fluent-support'),
826 - 'doc_url' => 'https://fluentsupport.com/docs/tutorlms-integration/',
1041 + 'doc_url' => 'https://docs.fluentsupport.com/tutorlms-integration',
827 1042 ],
828 1043 'telegram' => [
829 1044 'title' => __('Telegram', 'fluent-support'),
830 1045 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/telegram.jpeg',
@@ -829,9 +1044,9 @@
829 1044 'title' => __('Telegram', 'fluent-support'),
830 1045 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/telegram.jpeg',
831 1046 'is_integrated' => self::getFSIntegrationStatus('telegram_settings'),
832 1047 'description' => __('Business communication platform designed for security', 'fluent-support'),
833 - 'doc_url' => 'https://fluentsupport.com/docs/managing-tickets-using-telegram/',
1048 + 'doc_url' => 'https://docs.fluentsupport.com/managing-tickets-using-telegram',
834 1049 ],
835 1050 'fluent-crm' => [
836 1051 'title' => __('FluentCRM', 'fluent-support'),
837 1052 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-crm.png',
@@ -836,23 +1051,23 @@
836 1051 'title' => __('FluentCRM', 'fluent-support'),
837 1052 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-crm.png',
838 1053 'is_integrated' => defined('FLUENTCRM'),
839 1054 'description' => __('Self-hosted email and marketing automation for WordPress', 'fluent-support'),
840 - 'doc_url' => 'https://fluentsupport.com/docs/fluentcrm-integration/',
1055 + 'doc_url' => 'https://docs.fluentsupport.com/fluentcrm-integration',
841 1056 ],
842 1057 'fluent-community' => [
843 - 'title' => __('Fluent Community', 'fluent-support'),
1058 + 'title' => __('FluentCommunity', 'fluent-support'),
844 1059 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-community.png',
845 1060 'is_integrated' => defined('FLUENT_COMMUNITY_PLUGIN_VERSION'),
846 1061 'description' => __('Build and manage vibrant online communities with integrated LMS features directly within WordPress.', 'fluent-support'),
847 - 'doc_url' => 'https://fluentsupport.com/docs/fluentcommunity-integration/',
1062 + 'doc_url' => 'https://docs.fluentsupport.com/fluent-community-integration',
848 1063 ],
849 1064 'fluent-forms' => [
850 - 'title' => __('Fluent FORMS', 'fluent-support'),
1065 + 'title' => __('Fluent Forms', 'fluent-support'),
851 1066 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-forms.png',
852 1067 'is_integrated' => defined('FLUENTFORM'),
853 1068 'description' => __('A robust form plugin suitable for any business', 'fluent-support'),
854 - 'doc_url' => 'https://fluentsupport.com/docs/fluent-form-integration/',
1069 + 'doc_url' => 'https://docs.fluentsupport.com/fluent-form-integration',
855 1070 ],
856 1071 'buddy-boss' => [
857 1072 'title' => __('BuddyBoss', 'fluent-support'),
858 1073 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/buddy-boss.png',
@@ -857,9 +1072,9 @@
857 1072 'title' => __('BuddyBoss', 'fluent-support'),
858 1073 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/buddy-boss.png',
859 1074 'is_integrated' => defined('BP_PLUGIN_DIR'),
860 1075 'description' => __('Powerful platform for any member-focused business', 'fluent-support'),
861 - 'doc_url' => 'https://fluentsupport.com/docs/buddyboss-integration/'
1076 + 'doc_url' => 'https://docs.fluentsupport.com/buddyboss-integration'
862 1077 ],
863 1078 'discord' => [
864 1079 'title' => __('Discord', 'fluent-support'),
865 1080 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/discord.png',
@@ -864,9 +1079,9 @@
864 1079 'title' => __('Discord', 'fluent-support'),
865 1080 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/discord.png',
866 1081 'is_integrated' => self::getFSIntegrationStatus('discord_settings'),
867 1082 'description' => __('Business communication platform designed for tech', 'fluent-support'),
868 - 'doc_url' => 'https://fluentsupport.com/docs/managing-tickets-using-discord/',
1083 + 'doc_url' => 'https://docs.fluentsupport.com/managing-tickets-using-discord',
869 1084 ],
870 1085 'wishlist-member' => [
871 1086 'title' => __('WishList Member', 'fluent-support'),
872 1087 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/wishlist-member.png',
@@ -871,9 +1086,9 @@
871 1086 'title' => __('WishList Member', 'fluent-support'),
872 1087 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/wishlist-member.png',
873 1088 'is_integrated' => defined('WLM3_PLUGIN_VERSION'),
874 1089 'description' => __('Powerful platform for any member-focused business', 'fluent-support'),
875 - 'doc_url' => 'https://fluentsupport.com/docs/wishlist-member-integration/',
1090 + 'doc_url' => 'https://docs.fluentsupport.com/wishlist-member-integration',
876 1091 ],
877 1092 'easy-digital-downloads' => [
878 1093 'title' => __('Easy Digital Downloads', 'fluent-support'),
879 1094 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/easy-digital-downloads.png',
@@ -878,9 +1093,9 @@
878 1093 'title' => __('Easy Digital Downloads', 'fluent-support'),
879 1094 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/easy-digital-downloads.png',
880 1095 'is_integrated' => class_exists('\Easy_Digital_Downloads'),
881 1096 'description' => __('The ultimate WordPress platform for digital products', 'fluent-support'),
882 - 'doc_url' => 'https://fluentsupport.com/docs/edd-integration/',
1097 + 'doc_url' => 'https://docs.fluentsupport.com/edd-integration',
883 1098 ],
884 1099 'restrict-content-pro' => [
885 1100 'title' => __('Restrict Content pro', 'fluent-support'),
886 1101 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/restrict-content-pro.png',
@@ -885,9 +1100,9 @@
885 1100 'title' => __('Restrict Content pro', 'fluent-support'),
886 1101 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/restrict-content-pro.png',
887 1102 'is_integrated' => class_exists('\Restrict_Content_Pro' ),
888 1103 'description' => __('Powerful platform for any member-focused business', 'fluent-support'),
889 - 'doc_url' => 'https://fluentsupport.com/docs/restrict-content-pro-integration/',
1104 + 'doc_url' => 'https://docs.fluentsupport.com/restrict-content-pro-integration',
890 1105 ],
891 1106 'better-docs' => [
892 1107 'title' => __('BetterDocs', 'fluent-support'),
893 1108 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/better-docs.png',
@@ -892,9 +1107,9 @@
892 1107 'title' => __('BetterDocs', 'fluent-support'),
893 1108 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/better-docs.png',
894 1109 'is_integrated' => false,
895 1110 'description' => __('The standard plugin for knowledge base and documentation', 'fluent-support'),
896 - 'doc_url' => 'https://fluentsupport.com/docs/betterdocs-integration/',
1111 + 'doc_url' => 'https://docs.fluentsupport.com/betterdocs-integration',
897 1112 ],
898 1113 'whatsapp' => [
899 1114 'title' => __('WhatsApp', 'fluent-support'),
900 1115 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/whatsapp.jpeg',
@@ -899,9 +1114,9 @@
899 1114 'title' => __('WhatsApp', 'fluent-support'),
900 1115 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/whatsapp.jpeg',
901 1116 'is_integrated' => self::getFSIntegrationStatus('twilio_settings'),
902 1117 'description' => __('Business communication platform designed for privacy', 'fluent-support'),
903 - 'doc_url' => 'https://fluentsupport.com/docs/whatsapp-integration-via-twilio/',
1118 + 'doc_url' => 'https://docs.fluentsupport.com/whatsapp-integration-via-twilio',
904 1119 ],
905 1120 'paymattic' => [
906 1121 'title' => __('Paymattic', 'fluent-support'),
907 1122 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/paymattic.png',
@@ -913,9 +1128,9 @@
913 1128 'title' => __('LearnDash', 'fluent-support'),
914 1129 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/learn-dash.png',
915 1130 'is_integrated' => defined('LEARNDASH_VERSION'),
916 1131 'description' => __('The leading course platform built for WordPress', 'fluent-support'),
917 - 'doc_url' => 'https://fluentsupport.com/docs/learndash-integration/',
1132 + 'doc_url' => 'https://docs.fluentsupport.com/learndash-integration',
918 1133 ],
919 1134 'learn-press' => [
920 1135 'title' => __('LearnPress', 'fluent-support'),
921 1136 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/learn-press.png',
@@ -920,9 +1135,9 @@
920 1135 'title' => __('LearnPress', 'fluent-support'),
921 1136 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/learn-press.png',
922 1137 'is_integrated' => defined('LP_PLUGIN_FILE'),
923 1138 'description' => __('Course and e-learning platform built for WordPress', 'fluent-support'),
924 - 'doc_url' => 'https://fluentsupport.com/docs/learnpress-integration/',
1139 + 'doc_url' => 'https://docs.fluentsupport.com/learnpress-integration',
925 1140 ],
926 1141 'google-drive' => [
927 1142 'title' => __('Google Drive', 'fluent-support'),
928 1143 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/google-drive.jpeg',
@@ -927,9 +1142,9 @@
927 1142 'title' => __('Google Drive', 'fluent-support'),
928 1143 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/google-drive.jpeg',
929 1144 'is_integrated' => self::getFSIntegrationStatus('google_drive_settings'),
930 1145 'description' => __('A cloud storage service by Google for storing, syncing, and sharing files.', 'fluent-support'),
931 - 'doc_url' => 'https://fluentsupport.com/docs/google-drive-integration/'
1146 + 'doc_url' => 'https://docs.fluentsupport.com/google-drive-integration'
932 1147 ],
933 1148 'dropbox' => [
934 1149 'title' => __('Dropbox', 'fluent-support'),
935 1150 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/dropbox.png',
@@ -934,9 +1149,9 @@
934 1149 'title' => __('Dropbox', 'fluent-support'),
935 1150 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/dropbox.png',
936 1151 'is_integrated' => self::getFSIntegrationStatus('dropbox_settings'),
937 1152 'description' => __('A cloud-based file storage and sharing service that allows users to store files online and sync them across devices.', 'fluent-support'),
938 - 'doc_url' => 'https://fluentsupport.com/docs/dropbox-integration/',
1153 + 'doc_url' => 'https://docs.fluentsupport.com/dropbox-integration',
939 1154 ],
940 1155 'member-press' => [
941 1156 'title' => __('MemberPress', 'fluent-support'),
942 1157 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/member-press.png',
@@ -941,9 +1156,9 @@
941 1156 'title' => __('MemberPress', 'fluent-support'),
942 1157 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/member-press.png',
943 1158 'is_integrated' => class_exists('MeprUtils'),
944 1159 'description' => __('A WordPress plugin that enables the creation and management of membership sites, including content access control and subscription billing.', 'fluent-support'),
945 - 'doc_url' => 'https://fluentsupport.com/docs/memberpress-integration/'
1160 + 'doc_url' => 'https://docs.fluentsupport.com/memberpress-integration'
946 1161 ],
947 1162 'google-recaptcha' => [
948 1163 'title' => __('Google reCAPTCHA', 'fluent-support'),
949 1164 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/google-recaptcha.png',
@@ -948,9 +1163,9 @@
948 1163 'title' => __('Google reCAPTCHA', 'fluent-support'),
949 1164 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/google-recaptcha.png',
950 1165 'is_integrated' => self::getFSIntegrationStatus('recaptcha_setting'),
951 1166 'description' => __('A security service by Google designed to protect websites from bots and abuse by using challenges to distinguish between human and automated access.', 'fluent-support'),
952 - 'doc_url' => 'https://fluentsupport.com/docs/google-recaptcha-integration/',
1167 + 'doc_url' => 'https://docs.fluentsupport.com/google-recaptcha-integration',
953 1168 ],
954 1169 'fluent-boards' => [
955 1170 'title' => __('FluentBoards', 'fluent-support'),
956 1171 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-boards.png',
@@ -955,10 +1170,17 @@
955 1170 'title' => __('FluentBoards', 'fluent-support'),
956 1171 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-boards.png',
957 1172 'is_integrated' => defined('FLUENT_BOARDS'),
958 1173 'description' => __('A project management tool designed to streamline workflows and collaboration through customizable, kanban-style boards.', 'fluent-support'),
959 - 'doc_url' => '',
1174 + 'doc_url' => 'https://docs.fluentsupport.com/fluentboards-integrations#fluentboards-integration',
960 1175 ],
1176 + 'fluent-booking' => [
1177 + 'title' => __('FluentBooking', 'fluent-support'),
1178 + 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-booking.svg',
1179 + 'is_integrated' => defined('FLUENT_BOOKING_VERSION'),
1180 + 'description' => __('Appointment and booking management plugin for WordPress', 'fluent-support'),
1181 + 'doc_url' => 'https://docs.fluentsupport.com/fluentbooking-integration',
1182 + ],
961 1183 ];
962 1184
963 1185 return $connections;
964 1186 }
@@ -968,79 +1190,93 @@
968 1190 $menu = [
969 1191 [
970 1192 'title' => __('Global Settings', 'fluent-support'),
971 1193 'route_name' => 'global_settings',
972 - 'icon' => 'Document',
1194 + 'icon' => 'settings',
973 1195 ],
974 1196 [
975 1197 'title' => __('Ticket Tags', 'fluent-support'),
976 1198 'route_name' => 'tags',
977 - 'icon' => 'CollectionTag',
1199 + 'icon' => 'ticketTag',
978 1200 ],
979 1201 [
980 1202 'title' => __('Ticket Form Config', 'fluent-support'),
981 1203 'route_name' => 'ticket-form-config',
982 - 'icon' => 'Setting',
1204 + 'icon' => 'formConfig',
983 1205 ],
984 1206 [
985 1207 'title' => __('Custom Fields', 'fluent-support'),
986 1208 'route_name' => 'custom_fields',
987 - 'icon' => 'Tickets',
1209 + 'icon' => 'customFields',
988 1210 ],
989 1211 [
990 1212 'title' => __('Products', 'fluent-support'),
991 1213 'route_name' => 'products',
992 - 'icon' => 'Goods',
1214 + 'icon' => 'products',
993 1215 ],
994 1216 [
995 1217 'title' => __('Support Staff', 'fluent-support'),
996 1218 'route_name' => 'support-staffs',
997 - 'icon' => 'User',
1219 + 'icon' => 'supportStaffs',
998 1220 ],
999 1221 [
1000 1222 'title' => __('FluentCRM Integration', 'fluent-support'),
1001 1223 'route_name' => 'fluentcrm_integration',
1002 - 'icon' => 'Cpu',
1224 + 'icon' => 'crmIntegration',
1003 1225 ],
1004 1226 [
1005 1227 'title' => __('Incoming Webhook', 'fluent-support'),
1006 1228 'route_name' => 'incoming-webhook',
1007 - 'icon' => 'Connection',
1229 + 'icon' => 'incomingWebhook',
1008 1230 ],
1009 1231 [
1010 1232 'title' => __('Notification Integrations', 'fluent-support'),
1011 1233 'route_name' => 'integration',
1012 - 'icon' => 'AlarmClock',
1234 + 'icon' => 'notification',
1013 1235 ],
1014 1236 [
1015 1237 'title' => __('File Upload Integrations', 'fluent-support'),
1016 1238 'route_name' => 'upload_integration',
1017 - 'icon' => 'FolderAdd',
1239 + 'icon' => 'fileUpload',
1018 1240 ],
1019 1241 [
1242 + 'title' => __('Remote Portal', 'fluent-support'),
1243 + 'route_name' => 'remote_portal',
1244 + 'icon' => 'remotePortal',
1245 + ],
1246 + [
1020 1247 'title' => __('Auto Close Settings', 'fluent-support'),
1021 1248 'route_name' => 'auto_close',
1022 - 'icon' => 'Timer',
1249 + 'icon' => 'autoClose',
1023 1250 ],
1024 1251 [
1025 1252 'title' => __('Ticket Importer', 'fluent-support'),
1026 1253 'route_name' => 'ticket_importer',
1027 - 'icon' => 'Download',
1254 + 'icon' => 'importer',
1028 1255 ],
1029 1256 [
1030 1257 'title' => __('Recaptcha', 'fluent-support'),
1031 1258 'route_name' => 'reCaptcha',
1032 - 'icon' => 'Key',
1259 + 'icon' => 'reCaptcha',
1033 1260 ],
1034 1261 [
1035 1262 'title' => __('Integration Statuses', 'fluent-support'),
1036 1263 'route_name' => 'integration_statuses',
1037 - 'icon' => 'Connection',
1264 + 'icon' => 'status',
1038 1265 ],
1039 1266 [
1040 - 'title' => __('OpenAI Integration', 'fluent-support'),
1041 - 'route_name' => 'openai_integration',
1042 - 'icon' => 'Connection',
1267 + 'title' => __('AI Integration', 'fluent-support'),
1268 + 'icon' => 'aiIntegration',
1269 + 'children' => [
1270 + [
1271 + 'title' => __('AI Model Setup', 'fluent-support'),
1272 + 'route_name' => 'ai_integration',
1273 + ],
1274 + [
1275 + 'title' => __('MCP for AI Agents', 'fluent-support'),
1276 + 'route_name' => 'mcp_settings',
1277 + ],
1278 + ],
1043 1279 ],
1044 1280 ];
1045 1281
1046 1282 if (defined('FLUENT_SUPPORT_PRO_DIR_FILE')) {
@@ -1046,9 +1282,9 @@
1046 1282 if (defined('FLUENT_SUPPORT_PRO_DIR_FILE')) {
1047 1283 $menu[] = [
1048 1284 'title' => __('License Management', 'fluent-support'),
1049 1285 'route_name' => 'license',
1050 - 'icon' => 'Lock',
1286 + 'icon' => 'license',
1051 1287 ];
1052 1288 }
1053 1289
1054 1290 return apply_filters('fluent_support/settings_menu_items', $menu);
@@ -1053,8 +1289,9 @@
1053 1289
1054 1290 return apply_filters('fluent_support/settings_menu_items', $menu);
1055 1291 }
1056 1292
1293 + /** @internal Not called from core controllers — available for Pro/hook usage. */
1057 1294 public static function getFSIntegrationStatus($connection_name)
1058 1295 {
1059 1296 $integrationMap = [
1060 1297 'slack_settings' => 'slack_settings',
@@ -1082,10 +1319,10 @@
1082 1319 private static function checkNotificationIntegrationStatus($settingName)
1083 1320 {
1084 1321 $settings = self::getIntegrationOption($settingName, null);
1085 1322 if ($settings) {
1086 - $status = Arr::get($settings, 'status', false);
1087 - return $status ? true : false;
1323 + // status is stored as 'yes'/'no', and 'no' is truthy.
1324 + return Arr::get($settings, 'status') === 'yes';
1088 1325 }
1089 1326 return false;
1090 1327 }
1091 1328
@@ -1092,10 +1329,9 @@
1092 1329 private static function checkUploadDriverStatus($settingName)
1093 1330 {
1094 1331 $settings = self::getIntegrationOption($settingName, null);
1095 1332 if ($settings) {
1096 - $enabled = Arr::get($settings, 'status', false);
1097 - return $enabled ? true : false;
1333 + return Arr::get($settings, 'status') === 'yes';
1098 1334 }
1099 1335 return false;
1100 1336 }
1101 1337
@@ -1115,8 +1351,19 @@
1115 1351 {
1116 1352 $page = isset($data['page']) ? intval($data['page']) : 1;
1117 1353 $perPage = isset($data['per_page']) ? intval($data['per_page']) : 10;
1118 1354
1355 + if (!AIActivityLogs::tableExists()) {
1356 + return [
1357 + 'data' => [],
1358 + 'total' => 0,
1359 + 'per_page' => $perPage,
1360 + 'current_page' => $page,
1361 + 'last_page' => 1,
1362 + 'settings' => static::getSettings()['ai_activity_settings'],
1363 + ];
1364 + }
1365 +
1119 1366 $activitiesQuery = AIActivityLogs::with([
1120 1367 'person' => function ($query) {
1121 1368 $query->select(['first_name', 'person_type', 'last_name', 'id', 'avatar']);
1122 1369 },
@@ -1144,9 +1391,20 @@
1144 1391 if ($agentId) {
1145 1392 $activitiesQuery->where('agent_id', $agentId);
1146 1393 }
1147 1394
1148 - return $activitiesQuery->paginate($perPage, ['*'], 'page', $page);
1395 + $activities = $activitiesQuery->paginate($perPage, ['*'], 'page', $page);
1396 +
1397 + $settings = static::getSettings();
1398 +
1399 + return [
1400 + 'data' => $activities->items(),
1401 + 'total' => $activities->total(),
1402 + 'per_page' => $activities->perPage(),
1403 + 'current_page' => $activities->currentPage(),
1404 + 'last_page' => $activities->lastPage(),
1405 + 'settings' => $settings['ai_activity_settings']
1406 + ];
1149 1407 }
1150 1408
1151 1409 public static function updateAISettings($settings)
1152 1410 {
@@ -1160,9 +1418,9 @@
1160 1418
1161 1419 Helper::updateOption('_ai_activity_settings', $settings);
1162 1420
1163 1421 return [
1164 - 'message' => __('AI Activity settings has been updated', 'fluent-support')
1422 + 'message' => __('AI Activity settings have been updated', 'fluent-support')
1165 1423 ];
1166 1424 }
1167 1425
1168 1426 public static function getSettings()
@@ -1175,9 +1433,9 @@
1175 1433 ];
1176 1434
1177 1435 $settings = wp_parse_args($settings, $defaults);
1178 1436
1179 - if (! $settings ) throw new \Exception('No activity settings found');
1437 + if (! $settings ) throw new \Exception(esc_html__('No activity settings found', 'fluent-support'));
1180 1438
1181 1439 return [
1182 1440 'ai_activity_settings' => $settings
1183 1441 ];
@@ -1182,8 +1440,26 @@
1182 1440 'ai_activity_settings' => $settings
1183 1441 ];
1184 1442 }
1185 1443
1444 + /**
1445 + * @param string $optionKey
1446 + * @return bool
1447 + */
1448 + public static function areLogsDisabled($optionKey)
1449 + {
1450 + if (empty($optionKey)) {
1451 + return false;
1452 + }
1453 +
1454 + $settings = Helper::getOption($optionKey, []);
1455 + $defaults = [
1456 + 'disable_logs' => 'no'
1457 + ];
1458 + $settings = wp_parse_args($settings, $defaults);
1459 + return isset($settings['disable_logs']) && $settings['disable_logs'] === 'yes';
1460 + }
1461 +
1186 1462 public static function getIp($anonymize = false)
1187 1463 {
1188 1464 static $ipAddress;
1189 1465
@@ -1191,9 +1467,9 @@
1191 1467 return $ipAddress;
1192 1468 }
1193 1469
1194 1470 if (empty($_SERVER['REMOTE_ADDR'])) {
1195 - // It's a local cli request
1471 + // CLI request.
1196 1472 return '127.0.0.1';
1197 1473 }
1198 1474
1199 1475 $ipAddress = '';
@@ -1199,22 +1475,23 @@
1199 1475 $ipAddress = '';
1200 1476 $remoteAddr = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
1201 1477
1202 1478 if (isset($_SERVER["HTTP_CF_CONNECTING_IP"])) {
1203 - //If it's a valid Cloudflare request
1479 + // Only trust CF-Connecting-IP when the request really came from Cloudflare.
1204 1480 if (self::isCfIp($remoteAddr)) {
1205 - //Use the CF-Connecting-IP header.
1206 1481 $ipAddress = sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']));
1207 1482 } else {
1208 - //If it isn't valid, then use REMOTE_ADDR.
1209 1483 $ipAddress = $remoteAddr;
1210 1484 }
1211 1485 } else if ($remoteAddr == '127.0.0.1') {
1212 - // most probably it's local reverse proxy
1486 + // Likely a local reverse proxy.
1213 1487 if (isset($_SERVER["HTTP_CLIENT_IP"])) {
1214 1488 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
1215 1489 } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1216 - $ipAddress = (string)rest_is_ip_address(trim(current(preg_split('/,/', sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']))))));
1490 + $forwardedFor = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']));
1491 + $splitResult = preg_split('/,/', $forwardedFor);
1492 + $firstIp = is_array($splitResult) ? current($splitResult) : '';
1493 + $ipAddress = (string)rest_is_ip_address(trim((string)$firstIp));
1217 1494 }
1218 1495 }
1219 1496
1220 1497 if (!$ipAddress) {
@@ -1220,9 +1497,11 @@
1220 1497 if (!$ipAddress) {
1221 1498 $ipAddress = $remoteAddr;
1222 1499 }
1223 1500
1224 - $ipAddress = preg_replace('/^(\d+\.\d+\.\d+\.\d+):\d+$/', '\1', $ipAddress);
1501 + if ($ipAddress) {
1502 + $ipAddress = preg_replace('/^(\d+\.\d+\.\d+\.\d+):\d+$/', '\1', $ipAddress);
1503 + }
1225 1504
1226 1505 $ipAddress = apply_filters('fluent_auth/user_ip', $ipAddress);
1227 1506
1228 1507 if ($anonymize) {
@@ -1233,8 +1512,52 @@
1233 1512
1234 1513 return $ipAddress;
1235 1514 }
1236 1515
1516 + /**
1517 + * Count this request against $rateLimitKey and return true if the limit is exceeded.
1518 + *
1519 + * With a persistent object cache the increment is atomic. Without one it falls
1520 + * back to a best-effort transient read/write.
1521 + */
1522 + public static function hitRateLimit($rateLimitKey, $limit, $window = null)
1523 + {
1524 + $window = $window ?: 15 * MINUTE_IN_SECONDS;
1525 +
1526 + if (wp_using_ext_object_cache()) {
1527 + $group = 'fs_rate_limit';
1528 + if (false === wp_cache_get($rateLimitKey, $group)) {
1529 + wp_cache_add($rateLimitKey, 0, $group, $window);
1530 + }
1531 + $attempts = wp_cache_incr($rateLimitKey, 1, $group);
1532 +
1533 + // Fail closed if the cache couldn't increment.
1534 + if ($attempts === false) {
1535 + return true;
1536 + }
1537 +
1538 + return $attempts > $limit;
1539 + }
1540 +
1541 + $now = time();
1542 + $record = get_transient($rateLimitKey);
1543 +
1544 + // Keep `<=`: it guarantees the TTL below is at least 1. A TTL of 0 makes
1545 + // set_transient() never expire, which would lock the limiter forever.
1546 + if (!is_array($record) || empty($record['expires']) || $record['expires'] <= $now) {
1547 + $record = ['count' => 0, 'expires' => $now + $window];
1548 + }
1549 +
1550 + $record['count']++;
1551 +
1552 + // Use the time left, not $window. set_transient() resets expiry on every call,
1553 + // so a full window would let ongoing traffic keep the limiter tripped.
1554 + set_transient($rateLimitKey, $record, $record['expires'] - $now);
1555 +
1556 + return $record['count'] > $limit;
1557 + }
1558 +
1559 + /** @internal Not called from core controllers — available for Pro/hook usage. */
1237 1560 public static function isCfIp($ip = '')
1238 1561 {
1239 1562 if (!$ip) {
1240 1563 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
@@ -1256,13 +1579,10 @@
1256 1579 '197.234.240.0/22',
1257 1580 '198.41.128.0/17'
1258 1581 );
1259 1582 $validCFRequest = false;
1260 - //Make sure that the request came via Cloudflare.
1261 1583 foreach ($cloudflareIPRanges as $range) {
1262 - //Use the ip_in_range function from Joomla.
1263 1584 if (self::ipInRange($ip, $range)) {
1264 - //IP is valid. Belongs to Cloudflare.
1265 1585 return true;
1266 1586 }
1267 1587 }
1268 1588
@@ -1270,8 +1590,12 @@
1270 1590 }
1271 1591
1272 1592 private static function ipInRange($ip, $range)
1273 1593 {
1594 + if (!$ip || !$range || !is_string($ip) || !is_string($range)) {
1595 + return false;
1596 + }
1597 +
1274 1598 if (strpos($range, '/') !== false) {
1275 1599 // $range is in IP/NETMASK format
1276 1600 list($range, $netmask) = explode('/', $range, 2);
1277 1601 if (strpos($netmask, '.') !== false) {
@@ -1336,8 +1660,40 @@
1336 1660 $settings = Helper::getOption('_ticket_form_settings', []);
1337 1661 return Arr::get($settings, 'product_required_field') === 'yes';
1338 1662 }
1339 1663
1664 + /**
1665 + * Upgrade/pricing link with UTM params. The admin SPA doesn't build its own;
1666 + * it swaps utm_content on the URL localized into appVars ($upgradeUrl in start.js).
1667 + *
1668 + * @param string $content The utm_content placement.
1669 + * @param array $args Optional: 'campaign', 'base_url', plus any utm_* override.
1670 + * @return string
1671 + */
1672 + public static function getUpgradeUrl($content = 'upgrade_page', $args = [])
1673 + {
1674 + $args = wp_parse_args($args, [
1675 + 'campaign' => 'upgrade_pro',
1676 + 'base_url' => apply_filters('fluent_support/pro_upgrade_base_url', 'https://fluentsupport.com/pricing'),
1677 + ]);
1678 +
1679 + $params = [
1680 + 'utm_source' => 'fluent-support',
1681 + // Keep in sync with appVars.has_pro in Menu.php.
1682 + 'utm_medium' => defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') ? 'pro_plugin' : 'free_plugin',
1683 + 'utm_campaign' => $args['campaign'],
1684 + 'utm_content' => $content,
1685 + 'utm_term' => FLUENT_SUPPORT_VERSION,
1686 + ];
1687 +
1688 + $params = array_filter($params, function ($value) {
1689 + return $value !== '' && $value !== null;
1690 + });
1691 +
1692 + return add_query_arg($params, $args['base_url']);
1693 + }
1694 +
1695 + /** @internal Not called from core controllers — available for Pro/hook usage. */
1340 1696 public static function getBusinessBox()
1341 1697 {
1342 1698 $businessEmailBoxes = MailBox::select(['id', 'name', 'email', 'mapped_email'])
1343 1699 ->where('box_type', 'email')
@@ -1344,29 +1700,23 @@
1344 1700 ->get();
1345 1701 return $businessEmailBoxes;
1346 1702 }
1347 1703
1348 - public static function tempImageMoveUploadDir($ticketId, $contentType, $replyId = null)
1704 + public static function tempImageMoveUploadDir($ticketId, $contentType, $replyId = null, $personId = null)
1349 1705 {
1350 - // Fetch content based on the content type
1351 1706 $content = self::getContentByType($ticketId, $contentType , $replyId);
1352 1707 if (empty($content)) {
1353 1708 return;
1354 1709 }
1355 1710
1356 - // Extract image URLs from the content
1357 1711 $imageUrls = self::extractImageUrls($content);
1358 1712 if (empty($imageUrls)) {
1359 1713 return;
1360 1714 }
1361 1715
1362 - // Move images to the upload directory and update content
1363 - self::moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId);
1716 + self::moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId, $personId);
1364 1717 }
1365 1718
1366 - /**
1367 - * Fetch content based on the content type.
1368 - */
1369 1719 private static function getContentByType($ticketId, $contentType, $replyId)
1370 1720 {
1371 1721 if ($contentType == 'ticket-create') {
1372 1722 $ticket = Ticket::find($ticketId);
@@ -1376,54 +1726,206 @@
1376 1726 $conversation = Conversation::find($replyId);
1377 1727 return $conversation ? $conversation->content : null;
1378 1728 }
1379 1729
1380 - /**
1381 - * Extract image URLs from the content.
1382 - */
1383 1730 private static function extractImageUrls($content)
1384 1731 {
1385 - preg_match_all('/<img[^>]+src="([^">]+)"/', $content, $matches);
1386 - return $matches[1] ?? [];
1732 + preg_match_all('/<img[^>]+src=(["\'])(.*?)\1/i', (string) $content, $matches);
1733 + return $matches[2] ?? [];
1387 1734 }
1388 1735
1389 1736 /**
1390 - * Move images to the upload directory and update content.
1737 + * Move temp images into the ticket's folder and rewrite their URLs in the content.
1391 1738 */
1392 - private static function moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId)
1739 + private static function moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId, $personId)
1393 1740 {
1394 - // Get the current site's upload directory
1395 1741 $uploadDirInfo = wp_upload_dir();
1396 1742 $uploadsDir = $uploadDirInfo['basedir'];
1397 1743 $tempDir = $uploadsDir . '/fluent-support/temp_files/';
1744 + $signedAttachments = self::getSignedImageAttachments($imageUrls, $ticketId, $personId);
1398 1745
1399 1746 foreach ($imageUrls as $imageUrl) {
1400 - // Build the absolute path for the temporary file
1747 + $fileHash = self::extractAttachmentHashFromUrl($imageUrl);
1748 + if ($fileHash && isset($signedAttachments[$fileHash]) && ($referenceUrl = self::finalizeSignedTempImage($signedAttachments[$fileHash], $ticketId, $contentType, $replyId, $personId))) {
1749 + $content = str_replace($imageUrl, $referenceUrl, $content);
1750 + continue;
1751 + }
1752 +
1401 1753 $imageRelativePath = $tempDir . basename($imageUrl);
1402 1754 $absolutePath = $imageRelativePath;
1403 1755
1404 - // Move the file to the ticket-specific folder
1405 1756 $newFileInfo = UploadService::copyFileTicketFolder($absolutePath, $ticketId);
1406 1757
1407 - // Check if the move was successful
1408 1758 if (empty($newFileInfo['file_path'])) {
1409 - continue; // Skip if the file couldn't be copied
1759 + continue;
1410 1760 }
1411 1761
1412 - // Ensure the new URL is correctly constructed
1413 1762 $newFileInfo['url'] = trailingslashit($uploadDirInfo['baseurl']) . 'fluent-support/ticket_' . $ticketId . '/' . basename($newFileInfo['file_path']);
1414 1763
1415 - // Replace the old URL with the new one in the content
1416 1764 $content = str_replace($imageUrl, $newFileInfo['url'], $content);
1417 1765 }
1418 1766
1419 - // Save the updated content
1420 1767 self::saveUpdatedContent($ticketId, $contentType, $content, $replyId);
1421 1768 }
1422 1769
1423 - /**
1424 - * Save the updated content based on the content type.
1425 - */
1770 + private static function getSignedImageAttachments($imageUrls, $ticketId, $personId)
1771 + {
1772 + $fileHashes = [];
1773 + foreach ($imageUrls as $imageUrl) {
1774 + $fileHash = self::extractAttachmentHashFromUrl($imageUrl);
1775 + if ($fileHash) {
1776 + $fileHashes[] = $fileHash;
1777 + }
1778 + }
1779 +
1780 + $fileHashes = array_values(array_unique($fileHashes));
1781 + if (!$fileHashes) {
1782 + return [];
1783 + }
1784 +
1785 + $attachments = Attachment::whereIn('file_hash', $fileHashes)
1786 + ->where(function ($query) use ($ticketId, $personId) {
1787 + $query->where('ticket_id', $ticketId);
1788 +
1789 + if ($personId) {
1790 + $query->orWhere(function ($query) use ($personId) {
1791 + $query->whereNull('ticket_id')
1792 + ->where('person_id', $personId);
1793 + });
1794 + }
1795 + })
1796 + ->get();
1797 + $mappedAttachments = [];
1798 + foreach ($attachments as $attachment) {
1799 + $mappedAttachments[$attachment->file_hash] = $attachment;
1800 + }
1801 +
1802 + return $mappedAttachments;
1803 + }
1804 +
1805 + private static function finalizeSignedTempImage($attachment, $ticketId, $contentType, $replyId, $personId)
1806 + {
1807 + if (!$attachment || $attachment->driver !== 'local') {
1808 + return false;
1809 + }
1810 +
1811 + if ($attachment->ticket_id && intval($attachment->ticket_id) !== intval($ticketId)) {
1812 + return false;
1813 + }
1814 +
1815 + if (!$attachment->ticket_id && $personId && intval($attachment->person_id) !== intval($personId)) {
1816 + return false;
1817 + }
1818 +
1819 + if ($attachment->conversation_id && $replyId && intval($attachment->conversation_id) !== intval($replyId)) {
1820 + return false;
1821 + }
1822 +
1823 + if ($attachment->conversation_id && $contentType === 'ticket-create') {
1824 + return false;
1825 + }
1826 +
1827 + if ($attachment->status !== 'in-active') {
1828 + return $attachment->ticket_id ? self::getAttachmentReferenceUrl($attachment) : false;
1829 + }
1830 +
1831 + if (!$attachment->file_path || !file_exists($attachment->file_path)) {
1832 + return false;
1833 + }
1834 +
1835 + $newFileInfo = UploadService::copyFileTicketFolder($attachment->file_path, $ticketId);
1836 + if (empty($newFileInfo['file_path'])) {
1837 + return false;
1838 + }
1839 +
1840 + $attachment->file_path = $newFileInfo['file_path'];
1841 + $attachment->full_url = $newFileInfo['url'];
1842 + $attachment->ticket_id = $attachment->ticket_id ?: $ticketId;
1843 + $attachment->status = 'inline';
1844 +
1845 + if ($contentType !== 'ticket-create' && $replyId) {
1846 + $attachment->conversation_id = $replyId;
1847 + }
1848 +
1849 + $attachment->save();
1850 +
1851 + return self::getAttachmentReferenceUrl($attachment);
1852 + }
1853 +
1854 + private static function extractAttachmentHashFromUrl($imageUrl)
1855 + {
1856 + $decodedUrl = html_entity_decode($imageUrl, ENT_QUOTES, 'UTF-8');
1857 + $query = wp_parse_url($decodedUrl, PHP_URL_QUERY);
1858 +
1859 + if (!$query) {
1860 + return '';
1861 + }
1862 +
1863 + parse_str($query, $params);
1864 +
1865 + return !empty($params['fst_file']) ? sanitize_text_field($params['fst_file']) : '';
1866 + }
1867 +
1868 + private static function getAttachmentReferenceUrl($attachment)
1869 + {
1870 + return add_query_arg([
1871 + 'fst_file' => $attachment->file_hash
1872 + ], site_url('/index.php'));
1873 + }
1874 +
1875 + public static function refreshSignedAttachmentUrls($content, $ticketId = null)
1876 + {
1877 + $contents = self::refreshSignedAttachmentUrlsInContents([$content], $ticketId);
1878 +
1879 + return $contents[0];
1880 + }
1881 +
1882 + public static function refreshSignedAttachmentUrlsInContents($contents, $ticketId = null)
1883 + {
1884 + $fileHashes = [];
1885 +
1886 + foreach ($contents as $content) {
1887 + foreach (self::extractImageUrls($content) as $imageUrl) {
1888 + $fileHash = self::extractAttachmentHashFromUrl($imageUrl);
1889 + if ($fileHash) {
1890 + $fileHashes[] = $fileHash;
1891 + }
1892 + }
1893 + }
1894 +
1895 + $fileHashes = array_values(array_unique($fileHashes));
1896 + if (!$fileHashes) {
1897 + return $contents;
1898 + }
1899 +
1900 + $attachmentsQuery = Attachment::whereIn('file_hash', $fileHashes);
1901 + if ($ticketId) {
1902 + $attachmentsQuery->where('ticket_id', $ticketId);
1903 + }
1904 +
1905 + $attachments = $attachmentsQuery->get();
1906 + $attachmentsByHash = [];
1907 +
1908 + foreach ($attachments as $attachment) {
1909 + $attachmentsByHash[$attachment->file_hash] = $attachment;
1910 + }
1911 +
1912 + foreach ($contents as $key => $content) {
1913 + foreach (self::extractImageUrls($content) as $imageUrl) {
1914 + $fileHash = self::extractAttachmentHashFromUrl($imageUrl);
1915 + if (!$fileHash || empty($attachmentsByHash[$fileHash])) {
1916 + continue;
1917 + }
1918 +
1919 + $content = str_replace($imageUrl, $attachmentsByHash[$fileHash]->secureUrl, $content);
1920 + }
1921 +
1922 + $contents[$key] = $content;
1923 + }
1924 +
1925 + return $contents;
1926 + }
1927 +
1426 1928 private static function saveUpdatedContent($ticketId, $contentType, $content, $replyId)
1427 1929 {
1428 1930 if ($contentType == 'ticket-create') {
1429 1931 $ticket = Ticket::find($ticketId);
@@ -1440,16 +1942,43 @@
1440 1942 }
1441 1943 }
1442 1944
1443 1945 /**
1444 - * Safely unserialize data.
1946 + * Throw a ValidationException carrying the exception's message, or a generic one if empty.
1947 + * Throwing skips the framework's HTTP exception wrapper, which would prefix a status code.
1445 1948 *
1949 + * @param \Throwable $e The original exception.
1950 + * @throws \FluentSupport\Framework\Validator\ValidationException Always thrown.
1951 + */
1952 + public static function getSafeErrorMessage($e)
1953 + {
1954 + $message = $e->getMessage() ?: __('Something went wrong. Please try again later.', 'fluent-support');
1955 +
1956 + // Missing records are 404 and an exception thrown with 401/403/404/409
1957 + // keeps that status; anything else is a 422 as before. The body is the same.
1958 + $status = 422;
1959 + if ($e instanceof \FluentSupport\Framework\Database\Orm\ModelNotFoundException) {
1960 + $status = 404;
1961 + } elseif (in_array((int) $e->getCode(), [401, 403, 404, 409], true)) {
1962 + $status = (int) $e->getCode();
1963 + }
1964 +
1965 + // The message goes out through the framework's JSON error handler, never echoed.
1966 + // phpcs:disable covers the multi-line constructor call.
1967 + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
1968 + throw new \FluentSupport\Framework\Validator\ValidationException(
1969 + '', $status, ($e instanceof \Exception ? $e : null), ['message' => $message]
1970 + );
1971 + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
1972 + }
1973 +
1974 + /**
1446 1975 * @param string $data The serialized data.
1447 1976 * @return mixed The unserialized data or the original data if not serialized.
1448 1977 */
1449 1978 public static function safeUnserialize($data)
1450 1979 {
1451 - if (is_serialized($data)) { // Don't attempt to unserialize data that wasn't serialized going in.
1980 + if (is_serialized($data)) {
1452 1981 return @unserialize(trim($data), ['allowed_classes' => false]);
1453 1982 }
1454 1983
1455 1984 return $data;