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 -179 1.10.2 → 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,10 +317,10 @@
340 317
341 318 public static function getTicketViewUrl($ticket)
342 319 {
343 320 $baseUrl = self::getPortalBaseUrl();
344 -
345 - return $baseUrl . '/#/ticket/' . $ticket->id . '/view';
321 + $ticketNumber = $ticket->serial_number ?? $ticket->id;
322 + return $baseUrl . '/#/ticket/' . $ticketNumber . '/view';
346 323 }
347 324
348 325 public static function getTicketViewSignedUrl($ticket)
349 326 {
@@ -351,17 +328,17 @@
351 328 return self::getTicketViewUrl($ticket);
352 329 }
353 330
354 331 $baseUrl = self::getPortalBaseUrl();
332 + $ticketNumber = $ticket->serial_number ?? $ticket->id;
355 333
356 334 $baseUrl = add_query_arg([
357 335 'fs_view' => 'ticket',
358 336 'support_hash' => $ticket->hash,
359 - 'ticket_id' => $ticket->id,
360 - '_wpnonce' => wp_create_nonce('fluent-support')
337 + 'ticket_id' => $ticketNumber,
361 338 ], $baseUrl);
362 339
363 - return $baseUrl . '#/ticket/' . $ticket->id . '/view';
340 + return $baseUrl . '#/ticket/' . $ticketNumber . '/view';
364 341 }
365 342
366 343 public static function saveOpenAIData($objectType, $key, $data)
367 344 {
@@ -382,8 +359,98 @@
382 359 }
383 360
384 361 }
385 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 +
386 453 public static function authorizeChatGPTAPIKey($data)
387 454 {
388 455 return wp_remote_get('https://api.openai.com/v1/models', [
389 456 'headers' => [
@@ -406,44 +473,144 @@
406 473 return $baseUrl . 'tickets/' . $ticket->id . '/view';
407 474 }
408 475
409 476 /**
410 - * getPortalBaseUrl will get the portal page id and return link of the page
411 477 * @return mixed
412 478 */
413 479 public static function getPortalBaseUrl()
414 480 {
415 481 $businessSettings = self::getBusinessSettings();
416 - $baseUrl = get_permalink($businessSettings['portal_page_id']);
417 - $baseUrl = rtrim($baseUrl, '/\\');
418 - 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, '/\\'));
419 503 }
420 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 +
421 522 public static function getPortalAdminBaseUrl()
422 523 {
423 524 return apply_filters('fluent_support/portal_admin_base_url', admin_url('admin.php?page=fluent-support/#/'));
424 525 }
425 526
426 - 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)
427 536 {
428 537 static $settings;
429 538
430 - if ($settings && $key) {
431 - return Arr::get($settings, $key);
539 + if (!$settings) {
540 + $settings = (new Settings())->globalBusinessSettings();
432 541 }
433 542
434 - if ($settings) {
435 - return $settings;
543 + if ($key) {
544 + return Arr::get($settings, $key, $default);
436 545 }
437 546
438 - $settings = (new Settings())->globalBusinessSettings();
547 + return $settings;
548 + }
439 549
440 - if ($key) {
441 - 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 [];
442 586 }
443 - return $settings;
587 +
588 + return array_values(array_filter($keys, function ($key) {
589 + return is_string($key) && $key !== '' && !self::isProtectedUserMetaKey($key);
590 + }));
444 591 }
445 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 +
446 613 public static function isAgentFeedbackEnabled()
447 614 {
448 615 return self::getBusinessSettings('agent_feedback_rating', 'no') == 'yes';
449 616 }
@@ -518,11 +685,8 @@
518 685 }
519 686
520 687 public static function getCurrentAgent()
521 688 {
522 - // If user is logged in then return the agent by user id.
523 - // This `get_current_user_id` function is WP function and
524 - // it returns user id if user is logged in.
525 689 if (get_current_user_id()) {
526 690 return Agent::where('user_id', get_current_user_id())->first();
527 691 }
528 692 }
@@ -528,21 +692,19 @@
528 692 }
529 693
530 694 public static function getCurrentCustomer()
531 695 {
532 - // If user is logged in then return the customer by user id.
533 - // This `get_current_user_id` function is WP function and
534 - // it returns user id if user is logged in.
535 - if (get_current_user_id()) { //if user is logged in
536 - 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();
537 702 }
538 703 }
539 704
540 705 public static function getCurrentPerson()
541 706 {
542 - // If user is logged in then return the person(agent/customer) by user id.
543 - // This `get_current_user_id` function is WP function and
544 - // it returns user id if user is logged in.
545 707 if (get_current_user_id()) {
546 708 return Person::where('user_id', get_current_user_id())
547 709 ->orderBy('id', 'ASC')
548 710 ->first();
@@ -554,13 +716,14 @@
554 716 {
555 717 return Customer::where('id', $customerid)->first();
556 718 }
557 719
720 + /** @internal Not called from core controllers — available for Pro/hook usage. */
558 721 public static function sanitizeOrderValue($orderType = '')
559 722 {
560 723 $orderBys = ['ASC', 'DESC'];
561 724
562 - $orderType = trim(strtoupper($orderType));
725 + $orderType = trim(strtoupper((string)($orderType ?? '')));
563 726
564 727 return in_array($orderType, $orderBys) ? $orderType : 'DESC';
565 728 }
566 729
@@ -601,10 +764,9 @@
601 764 return $crmConfigs;
602 765 }
603 766
604 767 /**
605 - * getFluentCrmContactData method will get information from fluent crm using user email
606 - * @param $customer
768 + * @param Customer $customer
607 769 * @return array|false
608 770 */
609 771 public static function getFluentCrmContactData($customer)
610 772 {
@@ -610,9 +772,8 @@
610 772 {
611 773 if (!defined('FLUENTCRM')) {
612 774 return false;
613 775 }
614 - //Get contact info from FluentCRM using customer email
615 776 $contact = \FluentCrmApi('contacts')->getContactByUserRef($customer->email);
616 777 if ($contact) {
617 778 $tags = $contact->tags;
618 779 $lists = $contact->lists;
@@ -618,9 +779,8 @@
618 779 $lists = $contact->lists;
619 780 $urlBase = apply_filters('fluentcrm_menu_url_base', admin_url('admin.php?page=fluentcrm-admin#/'));
620 781 $crmProfileUrl = $urlBase . 'subscribers/' . $contact->id;
621 782
622 - //Return contact data
623 783 return [
624 784 'id' => $contact->id,
625 785 'first_name' => $contact->first_name,
626 786 'last_name' => $contact->last_name,
@@ -636,26 +796,31 @@
636 796
637 797 return false;
638 798 }
639 799
640 - public static function openAIIntegrationStatus() {
641 - $chatGPTSettingsData = Meta::where('object_type', '_fs_openai_settings')->value('value');
800 + public static function openAIIntegrationStatus()
801 + {
802 + $settings = static::getAIProviderSettings();
642 803
643 - if ($chatGPTSettingsData) {
644 - $settings = static::safeUnserialize($chatGPTSettingsData);
645 - return !empty($settings['api_key']);
804 + if (($settings['enabled'] ?? 'yes') === 'no') {
805 + return false;
646 806 }
647 807
648 - return false;
808 + return !empty($settings['api_key']);
649 809 }
650 810
651 811 public static function fluentBotIntegrationStatus()
652 812 {
653 - $settings = static::safeUnserialize(
654 - Meta::where('object_type', 'fluent_bot_settings')->value('value')
655 - );
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();
656 819
657 - 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);
658 823 }
659 824
660 825
661 826 public static function showTicketSummaryAdminBar()
@@ -670,8 +835,12 @@
670 835 }
671 836
672 837 public static function generateMessageID($email)
673 838 {
839 + if (!$email || !is_string($email)) {
840 + return false;
841 + }
842 +
674 843 $emailParts = explode('@', $email);
675 844 if (count($emailParts) != 2) {
676 845 return false;
677 846 }
@@ -715,9 +884,9 @@
715 884 public static function getAuthProvider()
716 885 {
717 886 if (defined('FLUENT_AUTH_PLUGIN_PATH')) {
718 887 $settings = \FluentAuth\App\Helpers\Helper::getAuthFormsSettings();
719 - if ($settings['enabled'] == 'yes') {
888 + if ($settings['enabled'] == 'yes' || FluentAuthBridge::shouldAdopt()) {
720 889 return 'fluent_auth';
721 890 }
722 891 }
723 892
@@ -723,12 +892,29 @@
723 892
724 893 return 'fluent_support';
725 894 }
726 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. */
727 911 public static function getDriversKey(){
728 912 return [
729 913 'dropbox_settings',
730 914 'google_drive_settings',
915 + 'cloudflare_r2_settings',
916 + 'amazon_s3_settings',
731 917 'local'
732 918 ];
733 919 }
734 920
@@ -744,11 +930,9 @@
744 930 if ($driver) {
745 931 return $driver;
746 932 }
747 933
748 - // Now guess the driver and save it
749 -
750 - // check if dropbox is enabled
934 + // No driver saved yet: use the first enabled one and remember it.
751 935 $dropboxSettings = self::getIntegrationOption('dropbox_settings', null);
752 936 if ($dropboxSettings) {
753 937 $dropBoxEnabled = Meta::where('object_type', 'enabled_upload_drivers')
754 938 ->where('key', 'dropbox_settings')
@@ -761,9 +945,8 @@
761 945 return $driver;
762 946 }
763 947 }
764 948
765 - // check if google drive is enabled
766 949 $googleDriveSettings = self::getIntegrationOption('google_drive_settings', null);
767 950
768 951 if ($googleDriveSettings) {
769 952 $googleDriveEnabled = Meta::where('object_type', 'enabled_upload_drivers')
@@ -777,8 +960,38 @@
777 960 return $driver;
778 961 }
779 962 }
780 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 +
781 994 self::updateOption('file_upload_driver', 'local');
782 995 return 'local';
783 996 }
784 997
@@ -789,16 +1002,16 @@
789 1002 'title' => __('WooCommerce', 'fluent-support'),
790 1003 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/woocommerce.png',
791 1004 'is_integrated' => defined('WC_PLUGIN_FILE'),
792 1005 'description' => __('The most popular e-commerce platform for WordPress', 'fluent-support'),
793 - 'doc_url' => 'https://fluentsupport.com/docs/woocommerce-integration/',
1006 + 'doc_url' => 'https://docs.fluentsupport.com/woocommerce-integration',
794 1007 ],
795 1008 'fluent-cart' => [
796 - 'title' => __('Fluent Cart', 'fluent-support'),
1009 + 'title' => __('FluentCart', 'fluent-support'),
797 1010 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-cart.webp',
798 1011 'is_integrated' => defined('FLUENTCART_VERSION'),
799 1012 'description' => __('A New Era of eCommerce with WordPress', 'fluent-support'),
800 - 'doc_url' => 'https://fluentsupport.com/docs/fluentcart-integration/',
1013 + 'doc_url' => 'https://docs.fluentsupport.com/fluentcart-integration',
801 1014 ],
802 1015 'lifter-lms' => [
803 1016 'title' => __('LifterLMS', 'fluent-support'),
804 1017 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/lifter-lms.png',
@@ -803,9 +1016,9 @@
803 1016 'title' => __('LifterLMS', 'fluent-support'),
804 1017 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/lifter-lms.png',
805 1018 'is_integrated' => defined('LLMS_PLUGIN_FILE'),
806 1019 'description' => __('Course and e-learning platform built for WordPress', 'fluent-support'),
807 - 'doc_url' => 'https://fluentsupport.com/docs/lifterlms-integration/',
1020 + 'doc_url' => 'https://docs.fluentsupport.com/lifterlms-integration',
808 1021 ],
809 1022 'slack' => [
810 1023 'title' => __('Slack', 'fluent-support'),
811 1024 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/slack.png',
@@ -810,9 +1023,9 @@
810 1023 'title' => __('Slack', 'fluent-support'),
811 1024 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/slack.png',
812 1025 'is_integrated' => self::getFSIntegrationStatus('slack_settings'),
813 1026 'description' => __('Business communication platform designed to scale', 'fluent-support'),
814 - 'doc_url' => 'https://fluentsupport.com/docs/managing-tickets-using-slack/',
1027 + 'doc_url' => 'https://docs.fluentsupport.com/managing-tickets-using-slack',
815 1028 ],
816 1029 'pm-pro' => [
817 1030 'title' => __('Paid Memberships Pro', 'fluent-support'),
818 1031 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/pmpro.png',
@@ -817,9 +1030,9 @@
817 1030 'title' => __('Paid Memberships Pro', 'fluent-support'),
818 1031 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/pmpro.png',
819 1032 'is_integrated' => defined('PMPRO_VERSION'),
820 1033 'description' => __('The ultimate platform for any member-focused business', 'fluent-support'),
821 - 'doc_url' => 'https://fluentsupport.com/docs/paid-membership-pro-integration/',
1034 + 'doc_url' => 'https://docs.fluentsupport.com/paid-membership-pro-integration',
822 1035 ],
823 1036 'tutor-lms' => [
824 1037 'title' => __('Tutor LMS', 'fluent-support'),
825 1038 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/tutor-lms.png',
@@ -824,9 +1037,9 @@
824 1037 'title' => __('Tutor LMS', 'fluent-support'),
825 1038 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/tutor-lms.png',
826 1039 'is_integrated' => defined('TUTOR_VERSION'),
827 1040 'description' => __('Course and e-learning platform built for WordPress', 'fluent-support'),
828 - 'doc_url' => 'https://fluentsupport.com/docs/tutorlms-integration/',
1041 + 'doc_url' => 'https://docs.fluentsupport.com/tutorlms-integration',
829 1042 ],
830 1043 'telegram' => [
831 1044 'title' => __('Telegram', 'fluent-support'),
832 1045 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/telegram.jpeg',
@@ -831,9 +1044,9 @@
831 1044 'title' => __('Telegram', 'fluent-support'),
832 1045 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/telegram.jpeg',
833 1046 'is_integrated' => self::getFSIntegrationStatus('telegram_settings'),
834 1047 'description' => __('Business communication platform designed for security', 'fluent-support'),
835 - 'doc_url' => 'https://fluentsupport.com/docs/managing-tickets-using-telegram/',
1048 + 'doc_url' => 'https://docs.fluentsupport.com/managing-tickets-using-telegram',
836 1049 ],
837 1050 'fluent-crm' => [
838 1051 'title' => __('FluentCRM', 'fluent-support'),
839 1052 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-crm.png',
@@ -838,23 +1051,23 @@
838 1051 'title' => __('FluentCRM', 'fluent-support'),
839 1052 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-crm.png',
840 1053 'is_integrated' => defined('FLUENTCRM'),
841 1054 'description' => __('Self-hosted email and marketing automation for WordPress', 'fluent-support'),
842 - 'doc_url' => 'https://fluentsupport.com/docs/fluentcrm-integration/',
1055 + 'doc_url' => 'https://docs.fluentsupport.com/fluentcrm-integration',
843 1056 ],
844 1057 'fluent-community' => [
845 - 'title' => __('Fluent Community', 'fluent-support'),
1058 + 'title' => __('FluentCommunity', 'fluent-support'),
846 1059 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-community.png',
847 1060 'is_integrated' => defined('FLUENT_COMMUNITY_PLUGIN_VERSION'),
848 1061 'description' => __('Build and manage vibrant online communities with integrated LMS features directly within WordPress.', 'fluent-support'),
849 - 'doc_url' => 'https://fluentsupport.com/docs/fluentcommunity-integration/',
1062 + 'doc_url' => 'https://docs.fluentsupport.com/fluent-community-integration',
850 1063 ],
851 1064 'fluent-forms' => [
852 - 'title' => __('Fluent FORMS', 'fluent-support'),
1065 + 'title' => __('Fluent Forms', 'fluent-support'),
853 1066 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-forms.png',
854 1067 'is_integrated' => defined('FLUENTFORM'),
855 1068 'description' => __('A robust form plugin suitable for any business', 'fluent-support'),
856 - 'doc_url' => 'https://fluentsupport.com/docs/fluent-form-integration/',
1069 + 'doc_url' => 'https://docs.fluentsupport.com/fluent-form-integration',
857 1070 ],
858 1071 'buddy-boss' => [
859 1072 'title' => __('BuddyBoss', 'fluent-support'),
860 1073 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/buddy-boss.png',
@@ -859,9 +1072,9 @@
859 1072 'title' => __('BuddyBoss', 'fluent-support'),
860 1073 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/buddy-boss.png',
861 1074 'is_integrated' => defined('BP_PLUGIN_DIR'),
862 1075 'description' => __('Powerful platform for any member-focused business', 'fluent-support'),
863 - 'doc_url' => 'https://fluentsupport.com/docs/buddyboss-integration/'
1076 + 'doc_url' => 'https://docs.fluentsupport.com/buddyboss-integration'
864 1077 ],
865 1078 'discord' => [
866 1079 'title' => __('Discord', 'fluent-support'),
867 1080 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/discord.png',
@@ -866,9 +1079,9 @@
866 1079 'title' => __('Discord', 'fluent-support'),
867 1080 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/discord.png',
868 1081 'is_integrated' => self::getFSIntegrationStatus('discord_settings'),
869 1082 'description' => __('Business communication platform designed for tech', 'fluent-support'),
870 - 'doc_url' => 'https://fluentsupport.com/docs/managing-tickets-using-discord/',
1083 + 'doc_url' => 'https://docs.fluentsupport.com/managing-tickets-using-discord',
871 1084 ],
872 1085 'wishlist-member' => [
873 1086 'title' => __('WishList Member', 'fluent-support'),
874 1087 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/wishlist-member.png',
@@ -873,9 +1086,9 @@
873 1086 'title' => __('WishList Member', 'fluent-support'),
874 1087 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/wishlist-member.png',
875 1088 'is_integrated' => defined('WLM3_PLUGIN_VERSION'),
876 1089 'description' => __('Powerful platform for any member-focused business', 'fluent-support'),
877 - 'doc_url' => 'https://fluentsupport.com/docs/wishlist-member-integration/',
1090 + 'doc_url' => 'https://docs.fluentsupport.com/wishlist-member-integration',
878 1091 ],
879 1092 'easy-digital-downloads' => [
880 1093 'title' => __('Easy Digital Downloads', 'fluent-support'),
881 1094 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/easy-digital-downloads.png',
@@ -880,9 +1093,9 @@
880 1093 'title' => __('Easy Digital Downloads', 'fluent-support'),
881 1094 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/easy-digital-downloads.png',
882 1095 'is_integrated' => class_exists('\Easy_Digital_Downloads'),
883 1096 'description' => __('The ultimate WordPress platform for digital products', 'fluent-support'),
884 - 'doc_url' => 'https://fluentsupport.com/docs/edd-integration/',
1097 + 'doc_url' => 'https://docs.fluentsupport.com/edd-integration',
885 1098 ],
886 1099 'restrict-content-pro' => [
887 1100 'title' => __('Restrict Content pro', 'fluent-support'),
888 1101 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/restrict-content-pro.png',
@@ -887,9 +1100,9 @@
887 1100 'title' => __('Restrict Content pro', 'fluent-support'),
888 1101 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/restrict-content-pro.png',
889 1102 'is_integrated' => class_exists('\Restrict_Content_Pro' ),
890 1103 'description' => __('Powerful platform for any member-focused business', 'fluent-support'),
891 - 'doc_url' => 'https://fluentsupport.com/docs/restrict-content-pro-integration/',
1104 + 'doc_url' => 'https://docs.fluentsupport.com/restrict-content-pro-integration',
892 1105 ],
893 1106 'better-docs' => [
894 1107 'title' => __('BetterDocs', 'fluent-support'),
895 1108 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/better-docs.png',
@@ -894,9 +1107,9 @@
894 1107 'title' => __('BetterDocs', 'fluent-support'),
895 1108 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/better-docs.png',
896 1109 'is_integrated' => false,
897 1110 'description' => __('The standard plugin for knowledge base and documentation', 'fluent-support'),
898 - 'doc_url' => 'https://fluentsupport.com/docs/betterdocs-integration/',
1111 + 'doc_url' => 'https://docs.fluentsupport.com/betterdocs-integration',
899 1112 ],
900 1113 'whatsapp' => [
901 1114 'title' => __('WhatsApp', 'fluent-support'),
902 1115 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/whatsapp.jpeg',
@@ -901,9 +1114,9 @@
901 1114 'title' => __('WhatsApp', 'fluent-support'),
902 1115 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/whatsapp.jpeg',
903 1116 'is_integrated' => self::getFSIntegrationStatus('twilio_settings'),
904 1117 'description' => __('Business communication platform designed for privacy', 'fluent-support'),
905 - 'doc_url' => 'https://fluentsupport.com/docs/whatsapp-integration-via-twilio/',
1118 + 'doc_url' => 'https://docs.fluentsupport.com/whatsapp-integration-via-twilio',
906 1119 ],
907 1120 'paymattic' => [
908 1121 'title' => __('Paymattic', 'fluent-support'),
909 1122 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/paymattic.png',
@@ -915,9 +1128,9 @@
915 1128 'title' => __('LearnDash', 'fluent-support'),
916 1129 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/learn-dash.png',
917 1130 'is_integrated' => defined('LEARNDASH_VERSION'),
918 1131 'description' => __('The leading course platform built for WordPress', 'fluent-support'),
919 - 'doc_url' => 'https://fluentsupport.com/docs/learndash-integration/',
1132 + 'doc_url' => 'https://docs.fluentsupport.com/learndash-integration',
920 1133 ],
921 1134 'learn-press' => [
922 1135 'title' => __('LearnPress', 'fluent-support'),
923 1136 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/learn-press.png',
@@ -922,9 +1135,9 @@
922 1135 'title' => __('LearnPress', 'fluent-support'),
923 1136 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/learn-press.png',
924 1137 'is_integrated' => defined('LP_PLUGIN_FILE'),
925 1138 'description' => __('Course and e-learning platform built for WordPress', 'fluent-support'),
926 - 'doc_url' => 'https://fluentsupport.com/docs/learnpress-integration/',
1139 + 'doc_url' => 'https://docs.fluentsupport.com/learnpress-integration',
927 1140 ],
928 1141 'google-drive' => [
929 1142 'title' => __('Google Drive', 'fluent-support'),
930 1143 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/google-drive.jpeg',
@@ -929,9 +1142,9 @@
929 1142 'title' => __('Google Drive', 'fluent-support'),
930 1143 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/google-drive.jpeg',
931 1144 'is_integrated' => self::getFSIntegrationStatus('google_drive_settings'),
932 1145 'description' => __('A cloud storage service by Google for storing, syncing, and sharing files.', 'fluent-support'),
933 - 'doc_url' => 'https://fluentsupport.com/docs/google-drive-integration/'
1146 + 'doc_url' => 'https://docs.fluentsupport.com/google-drive-integration'
934 1147 ],
935 1148 'dropbox' => [
936 1149 'title' => __('Dropbox', 'fluent-support'),
937 1150 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/dropbox.png',
@@ -936,9 +1149,9 @@
936 1149 'title' => __('Dropbox', 'fluent-support'),
937 1150 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/dropbox.png',
938 1151 'is_integrated' => self::getFSIntegrationStatus('dropbox_settings'),
939 1152 'description' => __('A cloud-based file storage and sharing service that allows users to store files online and sync them across devices.', 'fluent-support'),
940 - 'doc_url' => 'https://fluentsupport.com/docs/dropbox-integration/',
1153 + 'doc_url' => 'https://docs.fluentsupport.com/dropbox-integration',
941 1154 ],
942 1155 'member-press' => [
943 1156 'title' => __('MemberPress', 'fluent-support'),
944 1157 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/member-press.png',
@@ -943,9 +1156,9 @@
943 1156 'title' => __('MemberPress', 'fluent-support'),
944 1157 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/member-press.png',
945 1158 'is_integrated' => class_exists('MeprUtils'),
946 1159 'description' => __('A WordPress plugin that enables the creation and management of membership sites, including content access control and subscription billing.', 'fluent-support'),
947 - 'doc_url' => 'https://fluentsupport.com/docs/memberpress-integration/'
1160 + 'doc_url' => 'https://docs.fluentsupport.com/memberpress-integration'
948 1161 ],
949 1162 'google-recaptcha' => [
950 1163 'title' => __('Google reCAPTCHA', 'fluent-support'),
951 1164 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/google-recaptcha.png',
@@ -950,9 +1163,9 @@
950 1163 'title' => __('Google reCAPTCHA', 'fluent-support'),
951 1164 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/google-recaptcha.png',
952 1165 'is_integrated' => self::getFSIntegrationStatus('recaptcha_setting'),
953 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'),
954 - 'doc_url' => 'https://fluentsupport.com/docs/google-recaptcha-integration/',
1167 + 'doc_url' => 'https://docs.fluentsupport.com/google-recaptcha-integration',
955 1168 ],
956 1169 'fluent-boards' => [
957 1170 'title' => __('FluentBoards', 'fluent-support'),
958 1171 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-boards.png',
@@ -957,10 +1170,17 @@
957 1170 'title' => __('FluentBoards', 'fluent-support'),
958 1171 'logo' => FLUENT_SUPPORT_PLUGIN_URL . 'assets/images/icons/integrations/fluent-boards.png',
959 1172 'is_integrated' => defined('FLUENT_BOARDS'),
960 1173 'description' => __('A project management tool designed to streamline workflows and collaboration through customizable, kanban-style boards.', 'fluent-support'),
961 - 'doc_url' => '',
1174 + 'doc_url' => 'https://docs.fluentsupport.com/fluentboards-integrations#fluentboards-integration',
962 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 + ],
963 1183 ];
964 1184
965 1185 return $connections;
966 1186 }
@@ -970,79 +1190,93 @@
970 1190 $menu = [
971 1191 [
972 1192 'title' => __('Global Settings', 'fluent-support'),
973 1193 'route_name' => 'global_settings',
974 - 'icon' => 'Document',
1194 + 'icon' => 'settings',
975 1195 ],
976 1196 [
977 1197 'title' => __('Ticket Tags', 'fluent-support'),
978 1198 'route_name' => 'tags',
979 - 'icon' => 'CollectionTag',
1199 + 'icon' => 'ticketTag',
980 1200 ],
981 1201 [
982 1202 'title' => __('Ticket Form Config', 'fluent-support'),
983 1203 'route_name' => 'ticket-form-config',
984 - 'icon' => 'Setting',
1204 + 'icon' => 'formConfig',
985 1205 ],
986 1206 [
987 1207 'title' => __('Custom Fields', 'fluent-support'),
988 1208 'route_name' => 'custom_fields',
989 - 'icon' => 'Tickets',
1209 + 'icon' => 'customFields',
990 1210 ],
991 1211 [
992 1212 'title' => __('Products', 'fluent-support'),
993 1213 'route_name' => 'products',
994 - 'icon' => 'Goods',
1214 + 'icon' => 'products',
995 1215 ],
996 1216 [
997 1217 'title' => __('Support Staff', 'fluent-support'),
998 1218 'route_name' => 'support-staffs',
999 - 'icon' => 'User',
1219 + 'icon' => 'supportStaffs',
1000 1220 ],
1001 1221 [
1002 1222 'title' => __('FluentCRM Integration', 'fluent-support'),
1003 1223 'route_name' => 'fluentcrm_integration',
1004 - 'icon' => 'Cpu',
1224 + 'icon' => 'crmIntegration',
1005 1225 ],
1006 1226 [
1007 1227 'title' => __('Incoming Webhook', 'fluent-support'),
1008 1228 'route_name' => 'incoming-webhook',
1009 - 'icon' => 'Connection',
1229 + 'icon' => 'incomingWebhook',
1010 1230 ],
1011 1231 [
1012 1232 'title' => __('Notification Integrations', 'fluent-support'),
1013 1233 'route_name' => 'integration',
1014 - 'icon' => 'AlarmClock',
1234 + 'icon' => 'notification',
1015 1235 ],
1016 1236 [
1017 1237 'title' => __('File Upload Integrations', 'fluent-support'),
1018 1238 'route_name' => 'upload_integration',
1019 - 'icon' => 'FolderAdd',
1239 + 'icon' => 'fileUpload',
1020 1240 ],
1021 1241 [
1242 + 'title' => __('Remote Portal', 'fluent-support'),
1243 + 'route_name' => 'remote_portal',
1244 + 'icon' => 'remotePortal',
1245 + ],
1246 + [
1022 1247 'title' => __('Auto Close Settings', 'fluent-support'),
1023 1248 'route_name' => 'auto_close',
1024 - 'icon' => 'Timer',
1249 + 'icon' => 'autoClose',
1025 1250 ],
1026 1251 [
1027 1252 'title' => __('Ticket Importer', 'fluent-support'),
1028 1253 'route_name' => 'ticket_importer',
1029 - 'icon' => 'Download',
1254 + 'icon' => 'importer',
1030 1255 ],
1031 1256 [
1032 1257 'title' => __('Recaptcha', 'fluent-support'),
1033 1258 'route_name' => 'reCaptcha',
1034 - 'icon' => 'Key',
1259 + 'icon' => 'reCaptcha',
1035 1260 ],
1036 1261 [
1037 1262 'title' => __('Integration Statuses', 'fluent-support'),
1038 1263 'route_name' => 'integration_statuses',
1039 - 'icon' => 'Connection',
1264 + 'icon' => 'status',
1040 1265 ],
1041 1266 [
1042 - 'title' => __('OpenAI Integration', 'fluent-support'),
1043 - 'route_name' => 'openai_integration',
1044 - '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 + ],
1045 1279 ],
1046 1280 ];
1047 1281
1048 1282 if (defined('FLUENT_SUPPORT_PRO_DIR_FILE')) {
@@ -1048,9 +1282,9 @@
1048 1282 if (defined('FLUENT_SUPPORT_PRO_DIR_FILE')) {
1049 1283 $menu[] = [
1050 1284 'title' => __('License Management', 'fluent-support'),
1051 1285 'route_name' => 'license',
1052 - 'icon' => 'Lock',
1286 + 'icon' => 'license',
1053 1287 ];
1054 1288 }
1055 1289
1056 1290 return apply_filters('fluent_support/settings_menu_items', $menu);
@@ -1055,8 +1289,9 @@
1055 1289
1056 1290 return apply_filters('fluent_support/settings_menu_items', $menu);
1057 1291 }
1058 1292
1293 + /** @internal Not called from core controllers — available for Pro/hook usage. */
1059 1294 public static function getFSIntegrationStatus($connection_name)
1060 1295 {
1061 1296 $integrationMap = [
1062 1297 'slack_settings' => 'slack_settings',
@@ -1084,10 +1319,10 @@
1084 1319 private static function checkNotificationIntegrationStatus($settingName)
1085 1320 {
1086 1321 $settings = self::getIntegrationOption($settingName, null);
1087 1322 if ($settings) {
1088 - $status = Arr::get($settings, 'status', false);
1089 - return $status ? true : false;
1323 + // status is stored as 'yes'/'no', and 'no' is truthy.
1324 + return Arr::get($settings, 'status') === 'yes';
1090 1325 }
1091 1326 return false;
1092 1327 }
1093 1328
@@ -1094,10 +1329,9 @@
1094 1329 private static function checkUploadDriverStatus($settingName)
1095 1330 {
1096 1331 $settings = self::getIntegrationOption($settingName, null);
1097 1332 if ($settings) {
1098 - $enabled = Arr::get($settings, 'status', false);
1099 - return $enabled ? true : false;
1333 + return Arr::get($settings, 'status') === 'yes';
1100 1334 }
1101 1335 return false;
1102 1336 }
1103 1337
@@ -1117,8 +1351,19 @@
1117 1351 {
1118 1352 $page = isset($data['page']) ? intval($data['page']) : 1;
1119 1353 $perPage = isset($data['per_page']) ? intval($data['per_page']) : 10;
1120 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 +
1121 1366 $activitiesQuery = AIActivityLogs::with([
1122 1367 'person' => function ($query) {
1123 1368 $query->select(['first_name', 'person_type', 'last_name', 'id', 'avatar']);
1124 1369 },
@@ -1146,9 +1391,20 @@
1146 1391 if ($agentId) {
1147 1392 $activitiesQuery->where('agent_id', $agentId);
1148 1393 }
1149 1394
1150 - 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 + ];
1151 1407 }
1152 1408
1153 1409 public static function updateAISettings($settings)
1154 1410 {
@@ -1162,9 +1418,9 @@
1162 1418
1163 1419 Helper::updateOption('_ai_activity_settings', $settings);
1164 1420
1165 1421 return [
1166 - 'message' => __('AI Activity settings has been updated', 'fluent-support')
1422 + 'message' => __('AI Activity settings have been updated', 'fluent-support')
1167 1423 ];
1168 1424 }
1169 1425
1170 1426 public static function getSettings()
@@ -1177,9 +1433,9 @@
1177 1433 ];
1178 1434
1179 1435 $settings = wp_parse_args($settings, $defaults);
1180 1436
1181 - if (! $settings ) throw new \Exception('No activity settings found');
1437 + if (! $settings ) throw new \Exception(esc_html__('No activity settings found', 'fluent-support'));
1182 1438
1183 1439 return [
1184 1440 'ai_activity_settings' => $settings
1185 1441 ];
@@ -1184,8 +1440,26 @@
1184 1440 'ai_activity_settings' => $settings
1185 1441 ];
1186 1442 }
1187 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 +
1188 1462 public static function getIp($anonymize = false)
1189 1463 {
1190 1464 static $ipAddress;
1191 1465
@@ -1193,9 +1467,9 @@
1193 1467 return $ipAddress;
1194 1468 }
1195 1469
1196 1470 if (empty($_SERVER['REMOTE_ADDR'])) {
1197 - // It's a local cli request
1471 + // CLI request.
1198 1472 return '127.0.0.1';
1199 1473 }
1200 1474
1201 1475 $ipAddress = '';
@@ -1201,22 +1475,23 @@
1201 1475 $ipAddress = '';
1202 1476 $remoteAddr = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
1203 1477
1204 1478 if (isset($_SERVER["HTTP_CF_CONNECTING_IP"])) {
1205 - //If it's a valid Cloudflare request
1479 + // Only trust CF-Connecting-IP when the request really came from Cloudflare.
1206 1480 if (self::isCfIp($remoteAddr)) {
1207 - //Use the CF-Connecting-IP header.
1208 1481 $ipAddress = sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']));
1209 1482 } else {
1210 - //If it isn't valid, then use REMOTE_ADDR.
1211 1483 $ipAddress = $remoteAddr;
1212 1484 }
1213 1485 } else if ($remoteAddr == '127.0.0.1') {
1214 - // most probably it's local reverse proxy
1486 + // Likely a local reverse proxy.
1215 1487 if (isset($_SERVER["HTTP_CLIENT_IP"])) {
1216 1488 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
1217 1489 } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1218 - $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));
1219 1494 }
1220 1495 }
1221 1496
1222 1497 if (!$ipAddress) {
@@ -1222,9 +1497,11 @@
1222 1497 if (!$ipAddress) {
1223 1498 $ipAddress = $remoteAddr;
1224 1499 }
1225 1500
1226 - $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 + }
1227 1504
1228 1505 $ipAddress = apply_filters('fluent_auth/user_ip', $ipAddress);
1229 1506
1230 1507 if ($anonymize) {
@@ -1235,8 +1512,52 @@
1235 1512
1236 1513 return $ipAddress;
1237 1514 }
1238 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. */
1239 1560 public static function isCfIp($ip = '')
1240 1561 {
1241 1562 if (!$ip) {
1242 1563 $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
@@ -1258,13 +1579,10 @@
1258 1579 '197.234.240.0/22',
1259 1580 '198.41.128.0/17'
1260 1581 );
1261 1582 $validCFRequest = false;
1262 - //Make sure that the request came via Cloudflare.
1263 1583 foreach ($cloudflareIPRanges as $range) {
1264 - //Use the ip_in_range function from Joomla.
1265 1584 if (self::ipInRange($ip, $range)) {
1266 - //IP is valid. Belongs to Cloudflare.
1267 1585 return true;
1268 1586 }
1269 1587 }
1270 1588
@@ -1272,8 +1590,12 @@
1272 1590 }
1273 1591
1274 1592 private static function ipInRange($ip, $range)
1275 1593 {
1594 + if (!$ip || !$range || !is_string($ip) || !is_string($range)) {
1595 + return false;
1596 + }
1597 +
1276 1598 if (strpos($range, '/') !== false) {
1277 1599 // $range is in IP/NETMASK format
1278 1600 list($range, $netmask) = explode('/', $range, 2);
1279 1601 if (strpos($netmask, '.') !== false) {
@@ -1338,8 +1660,40 @@
1338 1660 $settings = Helper::getOption('_ticket_form_settings', []);
1339 1661 return Arr::get($settings, 'product_required_field') === 'yes';
1340 1662 }
1341 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. */
1342 1696 public static function getBusinessBox()
1343 1697 {
1344 1698 $businessEmailBoxes = MailBox::select(['id', 'name', 'email', 'mapped_email'])
1345 1699 ->where('box_type', 'email')
@@ -1346,29 +1700,23 @@
1346 1700 ->get();
1347 1701 return $businessEmailBoxes;
1348 1702 }
1349 1703
1350 - public static function tempImageMoveUploadDir($ticketId, $contentType, $replyId = null)
1704 + public static function tempImageMoveUploadDir($ticketId, $contentType, $replyId = null, $personId = null)
1351 1705 {
1352 - // Fetch content based on the content type
1353 1706 $content = self::getContentByType($ticketId, $contentType , $replyId);
1354 1707 if (empty($content)) {
1355 1708 return;
1356 1709 }
1357 1710
1358 - // Extract image URLs from the content
1359 1711 $imageUrls = self::extractImageUrls($content);
1360 1712 if (empty($imageUrls)) {
1361 1713 return;
1362 1714 }
1363 1715
1364 - // Move images to the upload directory and update content
1365 - self::moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId);
1716 + self::moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId, $personId);
1366 1717 }
1367 1718
1368 - /**
1369 - * Fetch content based on the content type.
1370 - */
1371 1719 private static function getContentByType($ticketId, $contentType, $replyId)
1372 1720 {
1373 1721 if ($contentType == 'ticket-create') {
1374 1722 $ticket = Ticket::find($ticketId);
@@ -1378,54 +1726,206 @@
1378 1726 $conversation = Conversation::find($replyId);
1379 1727 return $conversation ? $conversation->content : null;
1380 1728 }
1381 1729
1382 - /**
1383 - * Extract image URLs from the content.
1384 - */
1385 1730 private static function extractImageUrls($content)
1386 1731 {
1387 - preg_match_all('/<img[^>]+src="([^">]+)"/', $content, $matches);
1388 - return $matches[1] ?? [];
1732 + preg_match_all('/<img[^>]+src=(["\'])(.*?)\1/i', (string) $content, $matches);
1733 + return $matches[2] ?? [];
1389 1734 }
1390 1735
1391 1736 /**
1392 - * 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.
1393 1738 */
1394 - private static function moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId)
1739 + private static function moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId, $personId)
1395 1740 {
1396 - // Get the current site's upload directory
1397 1741 $uploadDirInfo = wp_upload_dir();
1398 1742 $uploadsDir = $uploadDirInfo['basedir'];
1399 1743 $tempDir = $uploadsDir . '/fluent-support/temp_files/';
1744 + $signedAttachments = self::getSignedImageAttachments($imageUrls, $ticketId, $personId);
1400 1745
1401 1746 foreach ($imageUrls as $imageUrl) {
1402 - // 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 +
1403 1753 $imageRelativePath = $tempDir . basename($imageUrl);
1404 1754 $absolutePath = $imageRelativePath;
1405 1755
1406 - // Move the file to the ticket-specific folder
1407 1756 $newFileInfo = UploadService::copyFileTicketFolder($absolutePath, $ticketId);
1408 1757
1409 - // Check if the move was successful
1410 1758 if (empty($newFileInfo['file_path'])) {
1411 - continue; // Skip if the file couldn't be copied
1759 + continue;
1412 1760 }
1413 1761
1414 - // Ensure the new URL is correctly constructed
1415 1762 $newFileInfo['url'] = trailingslashit($uploadDirInfo['baseurl']) . 'fluent-support/ticket_' . $ticketId . '/' . basename($newFileInfo['file_path']);
1416 1763
1417 - // Replace the old URL with the new one in the content
1418 1764 $content = str_replace($imageUrl, $newFileInfo['url'], $content);
1419 1765 }
1420 1766
1421 - // Save the updated content
1422 1767 self::saveUpdatedContent($ticketId, $contentType, $content, $replyId);
1423 1768 }
1424 1769
1425 - /**
1426 - * Save the updated content based on the content type.
1427 - */
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 +
1428 1928 private static function saveUpdatedContent($ticketId, $contentType, $content, $replyId)
1429 1929 {
1430 1930 if ($contentType == 'ticket-create') {
1431 1931 $ticket = Ticket::find($ticketId);
@@ -1442,16 +1942,43 @@
1442 1942 }
1443 1943 }
1444 1944
1445 1945 /**
1446 - * 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.
1447 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 + /**
1448 1975 * @param string $data The serialized data.
1449 1976 * @return mixed The unserialized data or the original data if not serialized.
1450 1977 */
1451 1978 public static function safeUnserialize($data)
1452 1979 {
1453 - if (is_serialized($data)) { // Don't attempt to unserialize data that wasn't serialized going in.
1980 + if (is_serialized($data)) {
1454 1981 return @unserialize(trim($data), ['allowed_classes' => false]);
1455 1982 }
1456 1983
1457 1984 return $data;