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 +176 -150 2.3.2 → 2.4.5 View file →
@@ -16,17 +16,8 @@
16 16 use FluentSupport\App\Services\Includes\UploadService;
17 17 use FluentSupport\App\Services\EmailNotification\Settings;
18 18 use FluentSupport\Framework\Support\Arr;
19 19
20 -/**
21 - * Helper - REST API Helper Class
22 - *
23 - * App helper for REST API
24 - *
25 - * @package FluentSupport\App\Services
26 - *
27 - * @version 1.0.0
28 - */
29 20 class Helper
30 21 {
31 22 public static function FluentSupport($module = null)
32 23 {
@@ -33,12 +24,12 @@
33 24 return App::getInstance($module);
34 25 }
35 26
36 27 /**
37 - * Get agent information by user id
38 - * The function will get user id as parameter or get id from session and return agent information
39 - * @param null $userId
40 - * @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
41 32 */
42 33 public static function getAgentByUserId($userId = null)
43 34 {
44 35 if ($userId === null) {
@@ -50,10 +41,8 @@
50 41 return Agent::where('user_id', $userId)->first();
51 42 }
52 43
53 44 /**
54 - * This function will return the list of ticket priorities list for customer
55 - *
56 45 * @return mixed
57 46 */
58 47 public static function customerTicketPriorities()
59 48 {
@@ -64,10 +53,8 @@
64 53 ]);
65 54 }
66 55
67 56 /**
68 - * This function will return the list of ticket priorities list for Admin
69 - *
70 57 * @return mixed
71 58 */
72 59 public static function adminTicketPriorities()
73 60 {
@@ -79,10 +66,8 @@
79 66 }
80 67
81 68
82 69 /**
83 - * This function will return ticket status group
84 - *
85 70 * @return mixed
86 71 */
87 72 public static function ticketStatusGroups()
88 73 {
@@ -95,10 +80,8 @@
95 80 ]);
96 81 }
97 82
98 83 /**
99 - * This function will return custom ticket status group
100 - *
101 84 * @return mixed
102 85 */
103 86 public static function changeableTicketStatuses()
104 87 {
@@ -110,10 +93,8 @@
110 93 return apply_filters('fluent_support/changeable_ticket_statuses', $ticketStatus);
111 94 }
112 95
113 96 /**
114 - * This function will return ticket status list
115 - *
116 97 * @return mixed
117 98 */
118 99 public static function ticketStatuses()
119 100 {
@@ -230,17 +211,14 @@
230 211 ]);
231 212 }
232 213
233 214 /**
234 - * getOption method will return settings using key
235 - * This method will get key as parameter, fetch data from database, beautify the data and return
236 - * @param $key
237 - * @param string $default
215 + * @param string $key
216 + * @param mixed $default Returned when the option is missing or empty.
238 217 * @return mixed|string
239 218 */
240 219 public static function getOption($key, $default = '')
241 220 {
242 - //Get settings from meta table using the key
243 221 $data = Meta::where('object_type', 'option')
244 222 ->where('key', $key)
245 223 ->first();
246 224
@@ -254,22 +232,20 @@
254 232 return $default;
255 233 }
256 234
257 235 /**
258 - * updateOption method will update or insert settings
259 - * 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
260 - * @param $key
261 - * @param $value
236 + * Upsert an option row in the meta table.
237 + *
238 + * @param string $key
239 + * @param mixed $value
262 240 * @return mixed
263 241 */
264 242 public static function updateOption($key, $value)
265 243 {
266 - //Get settings from meta table using the key
267 244 $data = Meta::where('object_type', 'option')
268 245 ->where('key', $key)
269 246 ->first();
270 247
271 - //If data is available, update existing data and return
272 248 if ($data) {
273 249 return Meta::where('id', $data->id)
274 250 ->update([
275 251 'value' => maybe_serialize($value)
@@ -275,9 +251,8 @@
275 251 'value' => maybe_serialize($value)
276 252 ]);
277 253 }
278 254
279 - //If newly submit, create new record and return
280 255 return Meta::insert([
281 256 'object_type' => 'option',
282 257 'key' => $key,
283 258 'value' => maybe_serialize($value)
@@ -292,10 +267,9 @@
292 267 ->delete();
293 268 }
294 269
295 270 /**
296 - * getIntegrationOption method will return the integration settings by integration key
297 - * @param $key
271 + * @param string $key
298 272 * @param string $default
299 273 * @return mixed|string
300 274 */
301 275 public static function getIntegrationOption($key, $default = '')
@@ -314,11 +288,12 @@
314 288 return $default;
315 289 }
316 290
317 291 /**
318 - * updateIntegrationOption method will update existing settings or create new settings by integration key
319 - * @param $key
320 - * @param $value
292 + * Upsert an integration settings row.
293 + *
294 + * @param string $key
295 + * @param mixed $value
321 296 * @return mixed
322 297 */
323 298 public static function updateIntegrationOption($key, $value)
324 299 {
@@ -498,9 +473,8 @@
498 473 return $baseUrl . 'tickets/' . $ticket->id . '/view';
499 474 }
500 475
501 476 /**
502 - * getPortalBaseUrl will get the portal page id and return link of the page
503 477 * @return mixed
504 478 */
505 479 public static function getPortalBaseUrl()
506 480 {
@@ -549,28 +523,94 @@
549 523 {
550 524 return apply_filters('fluent_support/portal_admin_base_url', admin_url('admin.php?page=fluent-support/#/'));
551 525 }
552 526
553 - 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)
554 536 {
555 537 static $settings;
556 538
557 - if ($settings && $key) {
558 - return Arr::get($settings, $key);
539 + if (!$settings) {
540 + $settings = (new Settings())->globalBusinessSettings();
559 541 }
560 542
561 - if ($settings) {
562 - return $settings;
543 + if ($key) {
544 + return Arr::get($settings, $key, $default);
563 545 }
564 546
565 - $settings = (new Settings())->globalBusinessSettings();
547 + return $settings;
548 + }
566 549
567 - if ($key) {
568 - 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 [];
569 586 }
570 - return $settings;
587 +
588 + return array_values(array_filter($keys, function ($key) {
589 + return is_string($key) && $key !== '' && !self::isProtectedUserMetaKey($key);
590 + }));
571 591 }
572 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 +
573 613 public static function isAgentFeedbackEnabled()
574 614 {
575 615 return self::getBusinessSettings('agent_feedback_rating', 'no') == 'yes';
576 616 }
@@ -645,11 +685,8 @@
645 685 }
646 686
647 687 public static function getCurrentAgent()
648 688 {
649 - // If user is logged in then return the agent by user id.
650 - // This `get_current_user_id` function is WP function and
651 - // it returns user id if user is logged in.
652 689 if (get_current_user_id()) {
653 690 return Agent::where('user_id', get_current_user_id())->first();
654 691 }
655 692 }
@@ -655,21 +692,19 @@
655 692 }
656 693
657 694 public static function getCurrentCustomer()
658 695 {
659 - // If user is logged in then return the customer by user id.
660 - // This `get_current_user_id` function is WP function and
661 - // it returns user id if user is logged in.
662 - if (get_current_user_id()) { //if user is logged in
663 - 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();
664 702 }
665 703 }
666 704
667 705 public static function getCurrentPerson()
668 706 {
669 - // If user is logged in then return the person(agent/customer) by user id.
670 - // This `get_current_user_id` function is WP function and
671 - // it returns user id if user is logged in.
672 707 if (get_current_user_id()) {
673 708 return Person::where('user_id', get_current_user_id())
674 709 ->orderBy('id', 'ASC')
675 710 ->first();
@@ -729,10 +764,9 @@
729 764 return $crmConfigs;
730 765 }
731 766
732 767 /**
733 - * getFluentCrmContactData method will get information from fluent crm using user email
734 - * @param $customer
768 + * @param Customer $customer
735 769 * @return array|false
736 770 */
737 771 public static function getFluentCrmContactData($customer)
738 772 {
@@ -738,9 +772,8 @@
738 772 {
739 773 if (!defined('FLUENTCRM')) {
740 774 return false;
741 775 }
742 - //Get contact info from FluentCRM using customer email
743 776 $contact = \FluentCrmApi('contacts')->getContactByUserRef($customer->email);
744 777 if ($contact) {
745 778 $tags = $contact->tags;
746 779 $lists = $contact->lists;
@@ -746,9 +779,8 @@
746 779 $lists = $contact->lists;
747 780 $urlBase = apply_filters('fluentcrm_menu_url_base', admin_url('admin.php?page=fluentcrm-admin#/'));
748 781 $crmProfileUrl = $urlBase . 'subscribers/' . $contact->id;
749 782
750 - //Return contact data
751 783 return [
752 784 'id' => $contact->id,
753 785 'first_name' => $contact->first_name,
754 786 'last_name' => $contact->last_name,
@@ -777,11 +809,9 @@
777 809 }
778 810
779 811 public static function fluentBotIntegrationStatus()
780 812 {
781 - // Include object_id and order by id desc to always read the latest row; the
782 - // write path (saveFluentBotSettings) does not prune siblings, so duplicates
783 - // may exist and a non-deterministic read can return stale state.
813 + // saveFluentBotSettings() doesn't prune old rows, so read the newest one.
784 814 $meta = Meta::where([
785 815 'object_type' => 'fluent_bot_settings',
786 816 'object_id' => 1,
787 817 'key' => '_fs_fluent_bot_config'
@@ -854,9 +884,9 @@
854 884 public static function getAuthProvider()
855 885 {
856 886 if (defined('FLUENT_AUTH_PLUGIN_PATH')) {
857 887 $settings = \FluentAuth\App\Helpers\Helper::getAuthFormsSettings();
858 - if ($settings['enabled'] == 'yes') {
888 + if ($settings['enabled'] == 'yes' || FluentAuthBridge::shouldAdopt()) {
859 889 return 'fluent_auth';
860 890 }
861 891 }
862 892
@@ -862,8 +892,22 @@
862 892
863 893 return 'fluent_support';
864 894 }
865 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 +
866 910 /** @internal Not called from core controllers — available for Pro/hook usage. */
867 911 public static function getDriversKey(){
868 912 return [
869 913 'dropbox_settings',
@@ -886,11 +930,9 @@
886 930 if ($driver) {
887 931 return $driver;
888 932 }
889 933
890 - // Now guess the driver and save it
891 -
892 - // check if dropbox is enabled
934 + // No driver saved yet: use the first enabled one and remember it.
893 935 $dropboxSettings = self::getIntegrationOption('dropbox_settings', null);
894 936 if ($dropboxSettings) {
895 937 $dropBoxEnabled = Meta::where('object_type', 'enabled_upload_drivers')
896 938 ->where('key', 'dropbox_settings')
@@ -903,9 +945,8 @@
903 945 return $driver;
904 946 }
905 947 }
906 948
907 - // check if google drive is enabled
908 949 $googleDriveSettings = self::getIntegrationOption('google_drive_settings', null);
909 950
910 951 if ($googleDriveSettings) {
911 952 $googleDriveEnabled = Meta::where('object_type', 'enabled_upload_drivers')
@@ -919,9 +960,8 @@
919 960 return $driver;
920 961 }
921 962 }
922 963
923 - // check if cloudflare r2 is enabled
924 964 $cloudflareR2Settings = self::getIntegrationOption('cloudflare_r2_settings', null);
925 965
926 966 if ($cloudflareR2Settings) {
927 967 $cloudflareR2Enabled = Meta::where('object_type', 'enabled_upload_drivers')
@@ -935,9 +975,8 @@
935 975 return $driver;
936 976 }
937 977 }
938 978
939 - // check if amazon s3 is enabled
940 979 $amazonS3Settings = self::getIntegrationOption('amazon_s3_settings', null);
941 980
942 981 if ($amazonS3Settings) {
943 982 $amazonS3Enabled = Meta::where('object_type', 'enabled_upload_drivers')
@@ -1199,8 +1238,13 @@
1199 1238 'route_name' => 'upload_integration',
1200 1239 'icon' => 'fileUpload',
1201 1240 ],
1202 1241 [
1242 + 'title' => __('Remote Portal', 'fluent-support'),
1243 + 'route_name' => 'remote_portal',
1244 + 'icon' => 'remotePortal',
1245 + ],
1246 + [
1203 1247 'title' => __('Auto Close Settings', 'fluent-support'),
1204 1248 'route_name' => 'auto_close',
1205 1249 'icon' => 'autoClose',
1206 1250 ],
@@ -1275,10 +1319,10 @@
1275 1319 private static function checkNotificationIntegrationStatus($settingName)
1276 1320 {
1277 1321 $settings = self::getIntegrationOption($settingName, null);
1278 1322 if ($settings) {
1279 - $status = Arr::get($settings, 'status', false);
1280 - return $status ? true : false;
1323 + // status is stored as 'yes'/'no', and 'no' is truthy.
1324 + return Arr::get($settings, 'status') === 'yes';
1281 1325 }
1282 1326 return false;
1283 1327 }
1284 1328
@@ -1285,10 +1329,9 @@
1285 1329 private static function checkUploadDriverStatus($settingName)
1286 1330 {
1287 1331 $settings = self::getIntegrationOption($settingName, null);
1288 1332 if ($settings) {
1289 - $enabled = Arr::get($settings, 'status', false);
1290 - return $enabled ? true : false;
1333 + return Arr::get($settings, 'status') === 'yes';
1291 1334 }
1292 1335 return false;
1293 1336 }
1294 1337
@@ -1308,8 +1351,19 @@
1308 1351 {
1309 1352 $page = isset($data['page']) ? intval($data['page']) : 1;
1310 1353 $perPage = isset($data['per_page']) ? intval($data['per_page']) : 10;
1311 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 +
1312 1366 $activitiesQuery = AIActivityLogs::with([
1313 1367 'person' => function ($query) {
1314 1368 $query->select(['first_name', 'person_type', 'last_name', 'id', 'avatar']);
1315 1369 },
@@ -1387,10 +1441,9 @@
1387 1441 ];
1388 1442 }
1389 1443
1390 1444 /**
1391 - * Check if activity logs are disabled for a given option key
1392 - * @param string $optionKey The option key to check
1445 + * @param string $optionKey
1393 1446 * @return bool
1394 1447 */
1395 1448 public static function areLogsDisabled($optionKey)
1396 1449 {
@@ -1414,9 +1467,9 @@
1414 1467 return $ipAddress;
1415 1468 }
1416 1469
1417 1470 if (empty($_SERVER['REMOTE_ADDR'])) {
1418 - // It's a local cli request
1471 + // CLI request.
1419 1472 return '127.0.0.1';
1420 1473 }
1421 1474
1422 1475 $ipAddress = '';
@@ -1422,18 +1475,16 @@
1422 1475 $ipAddress = '';
1423 1476 $remoteAddr = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
1424 1477
1425 1478 if (isset($_SERVER["HTTP_CF_CONNECTING_IP"])) {
1426 - //If it's a valid Cloudflare request
1479 + // Only trust CF-Connecting-IP when the request really came from Cloudflare.
1427 1480 if (self::isCfIp($remoteAddr)) {
1428 - //Use the CF-Connecting-IP header.
1429 1481 $ipAddress = sanitize_text_field(wp_unslash($_SERVER['HTTP_CF_CONNECTING_IP']));
1430 1482 } else {
1431 - //If it isn't valid, then use REMOTE_ADDR.
1432 1483 $ipAddress = $remoteAddr;
1433 1484 }
1434 1485 } else if ($remoteAddr == '127.0.0.1') {
1435 - // most probably it's local reverse proxy
1486 + // Likely a local reverse proxy.
1436 1487 if (isset($_SERVER["HTTP_CLIENT_IP"])) {
1437 1488 $ipAddress = sanitize_text_field(wp_unslash($_SERVER["HTTP_CLIENT_IP"]));
1438 1489 } else if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1439 1490 $forwardedFor = sanitize_text_field(wp_unslash($_SERVER['HTTP_X_FORWARDED_FOR']));
@@ -1462,17 +1513,12 @@
1462 1513 return $ipAddress;
1463 1514 }
1464 1515
1465 1516 /**
1466 - * Atomically increments the counter for $rateLimitKey and reports whether the
1467 - * limit is now exceeded. On sites with a persistent external object cache
1468 - * (Redis/Memcached), wp_cache_incr() is a real atomic increment, so concurrent
1469 - * requests can't race past the limit. Without one, this falls back to a plain
1470 - * transient read/write (same best-effort behavior as the rest of this codebase's
1471 - * rate limiters, e.g. AuthController::incrementLoginAttempts).
1517 + * Count this request against $rateLimitKey and return true if the limit is exceeded.
1472 1518 *
1473 - * Note the counter is incremented before it is compared, so the returned value
1474 - * already accounts for the current request.
1519 + * With a persistent object cache the increment is atomic. Without one it falls
1520 + * back to a best-effort transient read/write.
1475 1521 */
1476 1522 public static function hitRateLimit($rateLimitKey, $limit, $window = null)
1477 1523 {
1478 1524 $window = $window ?: 15 * MINUTE_IN_SECONDS;
@@ -1483,10 +1529,9 @@
1483 1529 wp_cache_add($rateLimitKey, 0, $group, $window);
1484 1530 }
1485 1531 $attempts = wp_cache_incr($rateLimitKey, 1, $group);
1486 1532
1487 - // Fail closed: if the cache backend couldn't increment (evicted key, hiccup),
1488 - // treat the request as rate-limited rather than silently letting it through.
1533 + // Fail closed if the cache couldn't increment.
1489 1534 if ($attempts === false) {
1490 1535 return true;
1491 1536 }
1492 1537
@@ -1495,16 +1540,10 @@
1495 1540
1496 1541 $now = time();
1497 1542 $record = get_transient($rateLimitKey);
1498 1543
1499 - // A record without a live deadline means the window is over (or the record predates
1500 - // this format), so the count starts again. Anything else keeps the deadline it was
1501 - // created with.
1502 - //
1503 - // The `<=` must not be loosened to `<`: it is what guarantees the TTL below is at
1504 - // least 1. A request landing exactly on the deadline would otherwise compute a TTL
1505 - // of 0, and set_transient() reads 0 as "never expires" — wedging this limiter shut
1506 - // permanently.
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.
1507 1546 if (!is_array($record) || empty($record['expires']) || $record['expires'] <= $now) {
1508 1547 $record = ['count' => 0, 'expires' => $now + $window];
1509 1548 }
1510 1549
@@ -1509,12 +1548,10 @@
1509 1548 }
1510 1549
1511 1550 $record['count']++;
1512 1551
1513 - // The TTL is the time left until the original deadline, never the full window:
1514 - // set_transient() rewrites expiry on every call, so passing $window here would let
1515 - // rejected requests push the deadline forward and keep a tripped limiter tripped
1516 - // for as long as traffic kept arriving.
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.
1517 1554 set_transient($rateLimitKey, $record, $record['expires'] - $now);
1518 1555
1519 1556 return $record['count'] > $limit;
1520 1557 }
@@ -1542,13 +1579,10 @@
1542 1579 '197.234.240.0/22',
1543 1580 '198.41.128.0/17'
1544 1581 );
1545 1582 $validCFRequest = false;
1546 - //Make sure that the request came via Cloudflare.
1547 1583 foreach ($cloudflareIPRanges as $range) {
1548 - //Use the ip_in_range function from Joomla.
1549 1584 if (self::ipInRange($ip, $range)) {
1550 - //IP is valid. Belongs to Cloudflare.
1551 1585 return true;
1552 1586 }
1553 1587 }
1554 1588
@@ -1627,28 +1661,35 @@
1627 1661 return Arr::get($settings, 'product_required_field') === 'yes';
1628 1662 }
1629 1663
1630 1664 /**
1631 - * Build a fluentsupport.com upgrade/pricing link tagged with UTM params
1632 - * per the standard "Upgrade to Pro" link spec (utm_source is always
1633 - * "fluent-support"; utm_medium reflects free vs pro install).
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
1634 1671 */
1635 - public static function getUpgradeUrl($content, $args = [])
1672 + public static function getUpgradeUrl($content = 'upgrade_page', $args = [])
1636 1673 {
1637 1674 $args = wp_parse_args($args, [
1638 1675 'campaign' => 'upgrade_pro',
1639 - 'base_url' => 'https://fluentsupport.com/pricing'
1676 + 'base_url' => apply_filters('fluent_support/pro_upgrade_base_url', 'https://fluentsupport.com/pricing'),
1640 1677 ]);
1641 1678
1642 1679 $params = [
1643 1680 'utm_source' => 'fluent-support',
1644 - 'utm_medium' => defined('FLUENTSUPPORTPRO') ? 'pro_plugin' : 'free_plugin',
1681 + // Keep in sync with appVars.has_pro in Menu.php.
1682 + 'utm_medium' => defined('FLUENTSUPPORTPRO_PLUGIN_VERSION') ? 'pro_plugin' : 'free_plugin',
1645 1683 'utm_campaign' => $args['campaign'],
1646 1684 'utm_content' => $content,
1647 1685 'utm_term' => FLUENT_SUPPORT_VERSION,
1648 - 'utm_id' => ''
1649 1686 ];
1650 1687
1688 + $params = array_filter($params, function ($value) {
1689 + return $value !== '' && $value !== null;
1690 + });
1691 +
1651 1692 return add_query_arg($params, $args['base_url']);
1652 1693 }
1653 1694
1654 1695 /** @internal Not called from core controllers — available for Pro/hook usage. */
@@ -1661,27 +1702,21 @@
1661 1702 }
1662 1703
1663 1704 public static function tempImageMoveUploadDir($ticketId, $contentType, $replyId = null, $personId = null)
1664 1705 {
1665 - // Fetch content based on the content type
1666 1706 $content = self::getContentByType($ticketId, $contentType , $replyId);
1667 1707 if (empty($content)) {
1668 1708 return;
1669 1709 }
1670 1710
1671 - // Extract image URLs from the content
1672 1711 $imageUrls = self::extractImageUrls($content);
1673 1712 if (empty($imageUrls)) {
1674 1713 return;
1675 1714 }
1676 1715
1677 - // Move images to the upload directory and update content
1678 1716 self::moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId, $personId);
1679 1717 }
1680 1718
1681 - /**
1682 - * Fetch content based on the content type.
1683 - */
1684 1719 private static function getContentByType($ticketId, $contentType, $replyId)
1685 1720 {
1686 1721 if ($contentType == 'ticket-create') {
1687 1722 $ticket = Ticket::find($ticketId);
@@ -1691,11 +1726,8 @@
1691 1726 $conversation = Conversation::find($replyId);
1692 1727 return $conversation ? $conversation->content : null;
1693 1728 }
1694 1729
1695 - /**
1696 - * Extract image URLs from the content.
1697 - */
1698 1730 private static function extractImageUrls($content)
1699 1731 {
1700 1732 preg_match_all('/<img[^>]+src=(["\'])(.*?)\1/i', (string) $content, $matches);
1701 1733 return $matches[2] ?? [];
@@ -1701,13 +1733,12 @@
1701 1733 return $matches[2] ?? [];
1702 1734 }
1703 1735
1704 1736 /**
1705 - * 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.
1706 1738 */
1707 1739 private static function moveImagesAndUpdateContent($imageUrls, $ticketId, $contentType, $content, $replyId, $personId)
1708 1740 {
1709 - // Get the current site's upload directory
1710 1741 $uploadDirInfo = wp_upload_dir();
1711 1742 $uploadsDir = $uploadDirInfo['basedir'];
1712 1743 $tempDir = $uploadsDir . '/fluent-support/temp_files/';
1713 1744 $signedAttachments = self::getSignedImageAttachments($imageUrls, $ticketId, $personId);
@@ -1718,28 +1749,22 @@
1718 1749 $content = str_replace($imageUrl, $referenceUrl, $content);
1719 1750 continue;
1720 1751 }
1721 1752
1722 - // Build the absolute path for the temporary file
1723 1753 $imageRelativePath = $tempDir . basename($imageUrl);
1724 1754 $absolutePath = $imageRelativePath;
1725 1755
1726 - // Move the file to the ticket-specific folder
1727 1756 $newFileInfo = UploadService::copyFileTicketFolder($absolutePath, $ticketId);
1728 1757
1729 - // Check if the move was successful
1730 1758 if (empty($newFileInfo['file_path'])) {
1731 - continue; // Skip if the file couldn't be copied
1759 + continue;
1732 1760 }
1733 1761
1734 - // Ensure the new URL is correctly constructed
1735 1762 $newFileInfo['url'] = trailingslashit($uploadDirInfo['baseurl']) . 'fluent-support/ticket_' . $ticketId . '/' . basename($newFileInfo['file_path']);
1736 1763
1737 - // Replace the old URL with the new one in the content
1738 1764 $content = str_replace($imageUrl, $newFileInfo['url'], $content);
1739 1765 }
1740 1766
1741 - // Save the updated content
1742 1767 self::saveUpdatedContent($ticketId, $contentType, $content, $replyId);
1743 1768 }
1744 1769
1745 1770 private static function getSignedImageAttachments($imageUrls, $ticketId, $personId)
@@ -1899,11 +1924,8 @@
1899 1924
1900 1925 return $contents;
1901 1926 }
1902 1927
1903 - /**
1904 - * Save the updated content based on the content type.
1905 - */
1906 1928 private static function saveUpdatedContent($ticketId, $contentType, $content, $replyId)
1907 1929 {
1908 1930 if ($contentType == 'ticket-create') {
1909 1931 $ticket = Ticket::find($ticketId);
@@ -1920,12 +1942,10 @@
1920 1942 }
1921 1943 }
1922 1944
1923 1945 /**
1924 - * Throw a ValidationException with a safe error message.
1925 - * In debug mode the real exception message is used; in production a generic message is returned.
1926 - * Throwing instead of returning ensures the error bypasses the framework's HTTP exception
1927 - * wrapper (which would prepend a status-code prefix to the message).
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.
1928 1948 *
1929 1949 * @param \Throwable $e The original exception.
1930 1950 * @throws \FluentSupport\Framework\Validator\ValidationException Always thrown.
1931 1951 */
@@ -1932,27 +1952,33 @@
1932 1952 public static function getSafeErrorMessage($e)
1933 1953 {
1934 1954 $message = $e->getMessage() ?: __('Something went wrong. Please try again later.', 'fluent-support');
1935 1955
1936 - // ValidationException carries the message through the framework's JSON error
1937 - // handler — it is never echoed directly. $e is the chained previous exception,
1938 - // not output. phpcs:disable covers the multi-line constructor call.
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.
1939 1967 // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped
1940 1968 throw new \FluentSupport\Framework\Validator\ValidationException(
1941 - '', 422, ($e instanceof \Exception ? $e : null), ['message' => $message]
1969 + '', $status, ($e instanceof \Exception ? $e : null), ['message' => $message]
1942 1970 );
1943 1971 // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
1944 1972 }
1945 1973
1946 1974 /**
1947 - * Safely unserialize data.
1948 - *
1949 1975 * @param string $data The serialized data.
1950 1976 * @return mixed The unserialized data or the original data if not serialized.
1951 1977 */
1952 1978 public static function safeUnserialize($data)
1953 1979 {
1954 - if (is_serialized($data)) { // Don't attempt to unserialize data that wasn't serialized going in.
1980 + if (is_serialized($data)) {
1955 1981 return @unserialize(trim($data), ['allowed_classes' => false]);
1956 1982 }
1957 1983
1958 1984 return $data;