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 +717 -186 1.10.0 → 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 {
@@ -170,9 +152,10 @@
170 152 $mimeHeadings = self::getAcceptedMimeHeadings();
171 153 $settings = (new Settings())->globalBusinessSettings();
172 154 $maxFileSize = floatval($settings['max_file_size']);
173 155
174 - return sprintf(__('Supported Types: %s and max file size: %.01fMB', 'fluent-support'), implode(', ', $mimeHeadings), $maxFileSize);
156 + // translators: %1$s is a comma-separated list of supported file types, %2$.01f is the maximum file size in megabytes
157 + return sprintf(__('Supported Types: %1$s and max file size: %2$.01fMB', 'fluent-support'), implode(', ', $mimeHeadings), $maxFileSize);
175 158 }
176 159
177 160 public static function getMimeGroups()
178 161 {
@@ -228,17 +211,14 @@
228 211 ]);
229 212 }
230 213
231 214 /**
232 - * getOption method will return settings using key
233 - * This method will get key as parameter, fetch data from database, beautify the data and return
234 - * @param $key
235 - * @param string $default
215 + * @param string $key
216 + * @param mixed $default Returned when the option is missing or empty.
236 217 * @return mixed|string
237 218 */
238 219 public static function getOption($key, $default = '')
239 220 {
240 - //Get settings from meta table using the key
241 221 $data = Meta::where('object_type', 'option')
242 222 ->where('key', $key)
243 223 ->first();
244 224
@@ -252,22 +232,20 @@
252 232 return $default;
253 233 }
254 234
255 235 /**
256 - * updateOption method will update or insert settings
257 - * 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
258 - * @param $key
259 - * @param $value
236 + * Upsert an option row in the meta table.
237 + *
238 + * @param string $key
239 + * @param mixed $value
260 240 * @return mixed
261 241 */
262 242 public static function updateOption($key, $value)
263 243 {
264 - //Get settings from meta table using the key
265 244 $data = Meta::where('object_type', 'option')
266 245 ->where('key', $key)
267 246 ->first();
268 247
269 - //If data is available, update existing data and return
270 248 if ($data) {
271 249 return Meta::where('id', $data->id)
272 250 ->update([
273 251 'value' => maybe_serialize($value)
@@ -273,9 +251,8 @@
273 251 'value' => maybe_serialize($value)
274 252 ]);
275 253 }
276 254
277 - //If newly submit, create new record and return
278 255 return Meta::insert([
279 256 'object_type' => 'option',
280 257 'key' => $key,
281 258 'value' => maybe_serialize($value)
@@ -281,8 +258,9 @@
281 258 'value' => maybe_serialize($value)
282 259 ]);
283 260 }
284 261
262 + /** @internal Not called from core controllers — available for Pro/hook usage. */
285 263 public static function deleteOption($key)
286 264 {
287 265 return Meta::where('object_type', 'option')
288 266 ->where('key', $key)
@@ -289,10 +267,9 @@
289 267 ->delete();
290 268 }
291 269
292 270 /**
293 - * getIntegrationOption method will return the integration settings by integration key
294 - * @param $key
271 + * @param string $key
295 272 * @param string $default
296 273 * @return mixed|string
297 274 */
298 275 public static function getIntegrationOption($key, $default = '')
@@ -311,11 +288,12 @@
311 288 return $default;
312 289 }
313 290
314 291 /**
315 - * updateIntegrationOption method will update existing settings or create new settings by integration key
316 - * @param $key
317 - * @param $value
292 + * Upsert an integration settings row.
293 + *
294 + * @param string $key
295 + * @param mixed $value
318 296 * @return mixed
319 297 */
320 298 public static function updateIntegrationOption($key, $value)
321 299 {
@@ -339,10 +317,10 @@
339 317
340 318 public static function getTicketViewUrl($ticket)
341 319 {
342 320 $baseUrl = self::getPortalBaseUrl();
343 -
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,36 +1467,41 @@
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 = '';
1476 + $remoteAddr = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
1477 +
1200 1478 if (isset($_SERVER["HTTP_CF_CONNECTING_IP"])) {
1201 - //If it's a valid Cloudflare request
1202 - if (self::isCfIp($_SERVER['REMOTE_ADDR'])) {
1203 - //Use the CF-Connecting-IP header.
1204 - $ipAddress = $_SERVER['HTTP_CF_CONNECTING_IP'];
1479 + // Only trust CF-Connecting-IP when the request really came from Cloudflare.
1480 + if (self::isCfIp($remoteAddr)) {
1481 + $ipAddress = sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']));
1205 1482 } else {
1206 - //If it isn't valid, then use REMOTE_ADDR.
1207 - $ipAddress = $_SERVER['REMOTE_ADDR'];
1483 + $ipAddress = $remoteAddr;
1208 1484 }
1209 - } else if ($_SERVER['REMOTE_ADDR'] == '127.0.0.1') {
1210 - // most probably it's local reverse proxy
1485 + } else if ($remoteAddr == '127.0.0.1') {
1486 + // Likely a local reverse proxy.
1211 1487 if (isset($_SERVER["HTTP_CLIENT_IP"])) {
1212 - $ipAddress = $_SERVER["HTTP_CLIENT_IP"];
1488 + $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
1213 1489 } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1214 - $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));
1215 1494 }
1216 1495 }
1217 1496
1218 1497 if (!$ipAddress) {
1219 - $ipAddress = $_SERVER['REMOTE_ADDR'];
1498 + $ipAddress = $remoteAddr;
1220 1499 }
1221 1500
1222 - $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 + }
1223 1504
1224 1505 $ipAddress = apply_filters('fluent_auth/user_ip', $ipAddress);
1225 1506
1226 1507 if ($anonymize) {
@@ -1231,12 +1512,56 @@
1231 1512
1232 1513 return $ipAddress;
1233 1514 }
1234 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. */
1235 1560 public static function isCfIp($ip = '')
1236 1561 {
1237 1562 if (!$ip) {
1238 - $ip = $_SERVER['REMOTE_ADDR'];
1563 + $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
1239 1564 }
1240 1565 $cloudflareIPRanges = array(
1241 1566 '103.21.244.0/22',
1242 1567 '103.22.200.0/22',
@@ -1254,13 +1579,10 @@
1254 1579 '197.234.240.0/22',
1255 1580 '198.41.128.0/17'
1256 1581 );
1257 1582 $validCFRequest = false;
1258 - //Make sure that the request came via Cloudflare.
1259 1583 foreach ($cloudflareIPRanges as $range) {
1260 - //Use the ip_in_range function from Joomla.
1261 1584 if (self::ipInRange($ip, $range)) {
1262 - //IP is valid. Belongs to Cloudflare.
1263 1585 return true;
1264 1586 }
1265 1587 }
1266 1588
@@ -1268,8 +1590,12 @@
1268 1590 }
1269 1591
1270 1592 private static function ipInRange($ip, $range)
1271 1593 {
1594 + if (!$ip || !$range || !is_string($ip) || !is_string($range)) {
1595 + return false;
1596 + }
1597 +
1272 1598 if (strpos($range, '/') !== false) {
1273 1599 // $range is in IP/NETMASK format
1274 1600 list($range, $netmask) = explode('/', $range, 2);
1275 1601 if (strpos($netmask, '.') !== false) {
@@ -1334,8 +1660,40 @@
1334 1660 $settings = Helper::getOption('_ticket_form_settings', []);
1335 1661 return Arr::get($settings, 'product_required_field') === 'yes';
1336 1662 }
1337 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. */
1338 1696 public static function getBusinessBox()
1339 1697 {
1340 1698 $businessEmailBoxes = MailBox::select(['id', 'name', 'email', 'mapped_email'])
1341 1699 ->where('box_type', 'email')
@@ -1342,29 +1700,23 @@
1342 1700 ->get();
1343 1701 return $businessEmailBoxes;
1344 1702 }
1345 1703
1346 - public static function tempImageMoveUploadDir($ticketId, $contentType, $replyId = null)
1704 + public static function tempImageMoveUploadDir($ticketId, $contentType, $replyId = null, $personId = null)
1347 1705 {
1348 - // Fetch content based on the content type
1349 1706 $content = self::getContentByType($ticketId, $contentType , $replyId);
1350 1707 if (empty($content)) {
1351 1708 return;
1352 1709 }
1353 1710
1354 - // Extract image URLs from the content
1355 1711 $imageUrls = self::extractImageUrls($content);
1356 1712 if (empty($imageUrls)) {
1357 1713 return;
1358 1714 }
1359 1715
1360 - // Move images to the upload directory and update content
1361 - self::moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId);
1716 + self::moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId, $personId);
1362 1717 }
1363 1718
1364 - /**
1365 - * Fetch content based on the content type.
1366 - */
1367 1719 private static function getContentByType($ticketId, $contentType, $replyId)
1368 1720 {
1369 1721 if ($contentType == 'ticket-create') {
1370 1722 $ticket = Ticket::find($ticketId);
@@ -1374,54 +1726,206 @@
1374 1726 $conversation = Conversation::find($replyId);
1375 1727 return $conversation ? $conversation->content : null;
1376 1728 }
1377 1729
1378 - /**
1379 - * Extract image URLs from the content.
1380 - */
1381 1730 private static function extractImageUrls($content)
1382 1731 {
1383 - preg_match_all('/<img[^>]+src="([^">]+)"/', $content, $matches);
1384 - return $matches[1] ?? [];
1732 + preg_match_all('/<img[^>]+src=(["\'])(.*?)\1/i', (string) $content, $matches);
1733 + return $matches[2] ?? [];
1385 1734 }
1386 1735
1387 1736 /**
1388 - * 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.
1389 1738 */
1390 - private static function moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId)
1739 + private static function moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId, $personId)
1391 1740 {
1392 - // Get the current site's upload directory
1393 1741 $uploadDirInfo = wp_upload_dir();
1394 1742 $uploadsDir = $uploadDirInfo['basedir'];
1395 1743 $tempDir = $uploadsDir . '/fluent-support/temp_files/';
1744 + $signedAttachments = self::getSignedImageAttachments($imageUrls, $ticketId, $personId);
1396 1745
1397 1746 foreach ($imageUrls as $imageUrl) {
1398 - // 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 +
1399 1753 $imageRelativePath = $tempDir . basename($imageUrl);
1400 1754 $absolutePath = $imageRelativePath;
1401 1755
1402 - // Move the file to the ticket-specific folder
1403 1756 $newFileInfo = UploadService::copyFileTicketFolder($absolutePath, $ticketId);
1404 1757
1405 - // Check if the move was successful
1406 1758 if (empty($newFileInfo['file_path'])) {
1407 - continue; // Skip if the file couldn't be copied
1759 + continue;
1408 1760 }
1409 1761
1410 - // Ensure the new URL is correctly constructed
1411 1762 $newFileInfo['url'] = trailingslashit($uploadDirInfo['baseurl']) . 'fluent-support/ticket_' . $ticketId . '/' . basename($newFileInfo['file_path']);
1412 1763
1413 - // Replace the old URL with the new one in the content
1414 1764 $content = str_replace($imageUrl, $newFileInfo['url'], $content);
1415 1765 }
1416 1766
1417 - // Save the updated content
1418 1767 self::saveUpdatedContent($ticketId, $contentType, $content, $replyId);
1419 1768 }
1420 1769
1421 - /**
1422 - * Save the updated content based on the content type.
1423 - */
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 +
1424 1928 private static function saveUpdatedContent($ticketId, $contentType, $content, $replyId)
1425 1929 {
1426 1930 if ($contentType == 'ticket-create') {
1427 1931 $ticket = Ticket::find($ticketId);
@@ -1438,16 +1942,43 @@
1438 1942 }
1439 1943 }
1440 1944
1441 1945 /**
1442 - * 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.
1443 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 + /**
1444 1975 * @param string $data The serialized data.
1445 1976 * @return mixed The unserialized data or the original data if not serialized.
1446 1977 */
1447 1978 public static function safeUnserialize($data)
1448 1979 {
1449 - if (is_serialized($data)) { // Don't attempt to unserialize data that wasn't serialized going in.
1980 + if (is_serialized($data)) {
1450 1981 return @unserialize(trim($data), ['allowed_classes' => false]);
1451 1982 }
1452 1983
1453 1984 return $data;