PluginProbe
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution / 2.5.0
Fluent Booking – The Ultimate Appointments Scheduling, Events Booking, Events Calendar Solution v2.5.0
2.5.0 2.4.0 2.3.0 2.2.5 2.2.0 2.1.2 2.1.1 trunk 1.10.0 1.10.01 1.10.02 1.5.0 1.5.01 1.5.02 1.5.1 1.5.10 1.5.20 1.5.21 1.5.22 1.5.23 1.5.24 1.5.25 1.6.0 1.7.0 1.7.1 All 34 releases
← All changes | app/Services/Helper.php +337 -29 1.10.02 → 2.5.0 View file →
@@ -7,8 +7,9 @@
7 7 use FluentBooking\App\Models\Calendar;
8 8 use FluentBooking\App\Models\CalendarSlot;
9 9 use FluentBooking\App\Models\Meta;
10 10 use FluentBooking\App\Models\BookingMeta;
11 +use FluentBooking\App\Modules\MCP\Support\SlotLock;
11 12 use FluentBooking\Framework\Support\Arr;
12 13
13 14 class Helper
14 15 {
@@ -693,11 +694,44 @@
693 694 {
694 695 return self::getAppBaseUrl('scheduled-events?booking_id=' . $bookingId);
695 696 }
696 697
697 - public static function getUpgradeUrl()
698 + /**
699 + * Build a spec-compliant "Upgrade to Pro" URL.
700 + *
701 + * Follows the shared Fluent* UTM spec:
702 + * utm_source = fluent-booking (fixed vocabulary, never the wp.org slug)
703 + * utm_medium = free_plugin | pro_plugin (acquisition vs cross-sell)
704 + * utm_campaign= upgrade_pro (override for xsell_<target> / license_* )
705 + * utm_content = the exact placement, e.g. feature_lock_team_calendar, upgrade_page
706 + * utm_term = plugin version that generated the link
707 + * utm_id = promo id, blank normally (omit unless passed)
708 + *
709 + * @param string $content The utm_content placement.
710 + * @param array $overrides Override any utm_* param (e.g. utm_campaign for cross-sell).
711 + * @return string
712 + */
713 + public static function getUpgradeUrl($content = 'upgrade_page', $overrides = [])
698 714 {
699 - return 'https://fluentbooking.com/pricing/?utm_source=plugin&utm_medium=wp_install&utm_campaign=fcal_upgrade&theme=' . self::getActiveThemeName();
715 + $baseUrl = apply_filters(
716 + 'fluent_booking/pro_upgrade_base_url',
717 + 'https://fluentbooking.com/pricing/'
718 + );
719 +
720 + $params = wp_parse_args($overrides, [
721 + 'utm_source' => 'fluent-booking',
722 + 'utm_medium' => defined('FLUENT_BOOKING_PRO_VERSION') ? 'pro_plugin' : 'free_plugin',
723 + 'utm_campaign' => 'upgrade_pro',
724 + 'utm_content' => $content,
725 + 'utm_term' => FLUENT_BOOKING_VERSION,
726 + ]);
727 +
728 + // Drop any blank params (e.g. an unset utm_id) so they never hit the URL.
729 + $params = array_filter($params, function ($value) {
730 + return $value !== '' && $value !== null;
731 + });
732 +
733 + return add_query_arg($params, $baseUrl);
700 734 }
701 735
702 736 public static function getNextBookingGroup()
703 737 {
@@ -883,15 +917,29 @@
883 917 }
884 918
885 919 $user = get_user_by('ID', $userId);
886 920
887 - $name = trim($user->first_name . ' ' . $user->last_name);
921 + return self::getDisplayNameFromUser($user);
922 + }
888 923
889 - if ($name) {
924 + public static function getDisplayNameFromUser($user)
925 + {
926 + if (!$user) {
927 + return '';
928 + }
929 +
930 + $firstName = is_object($user) ? ($user->first_name ?? '') : '';
931 + $lastName = is_object($user) ? ($user->last_name ?? '') : '';
932 +
933 + $name = trim($firstName . ' ' . $lastName);
934 +
935 + if ($name !== '') {
890 936 return $name;
891 937 }
892 938
893 - return $user->display_name;
939 + $displayName = is_object($user) ? ($user->display_name ?? '') : '';
940 +
941 + return (string) $displayName;
894 942 }
895 943
896 944 public static function getUserEmail($userId = null)
897 945 {
@@ -937,29 +985,177 @@
937 985
938 986 return apply_filters('fluent_booking/slot_slug', $default, $original);
939 987 }
940 988
941 - public static function getIp()
989 +
990 + public static function getIp($defalt = '127.0.0.1')
942 991 {
943 - $server = $_SERVER;
992 + static $ipAddress;
944 993
945 - $ipSources = [
946 - 'clientIp' => Arr::get($server, 'HTTP_CLIENT_IP'),
947 - 'xForwarded' => Arr::get($server, 'HTTP_X_FORWARDED_FOR'),
948 - 'serverAddr' => Arr::get($server, 'SERVER_ADDR')
949 - ];
994 + if ($ipAddress) {
995 + return $ipAddress;
996 + }
950 997
951 - $ip = '';
952 - foreach ($ipSources as $source) {
953 - if (!empty($source) && filter_var($source, FILTER_VALIDATE_IP)) {
954 - $ip = $source;
955 - break;
998 + if (empty($_SERVER['REMOTE_ADDR'])) {
999 + // It's a local cli request
1000 + return $defalt;
1001 + }
1002 +
1003 + $ipAddress = self::resolveClientIp($_SERVER);
1004 +
1005 + $ipAddress = apply_filters('fluent_booking/user_ip', $ipAddress, []);
1006 +
1007 + $ipAddress = sanitize_text_field(wp_unslash($ipAddress));
1008 +
1009 + return $ipAddress;
1010 + }
1011 +
1012 + /**
1013 + * Pick the client address out of a request's server vars.
1014 + *
1015 + * Kept apart from getIp(), which caches its answer for the request, so the
1016 + * header-trust rules can be exercised one request shape at a time.
1017 + *
1018 + * @param array $serverData $_SERVER or an equivalent
1019 + * @return string
1020 + */
1021 + public static function resolveClientIp($serverData)
1022 + {
1023 + $remoteAddr = preg_replace('/^(\d+\.\d+\.\d+\.\d+):\d+$/', '\1', (string)Arr::get($serverData, 'REMOTE_ADDR'));
1024 + $cloudflareIp = (string)Arr::get($serverData, 'HTTP_CF_CONNECTING_IP');
1025 + $trustedProxies = (array)apply_filters('fluent_booking/trusted_proxies', ['127.0.0.1']);
1026 +
1027 + // A proxy appends the peer it saw, so only the right-most hop is reliable;
1028 + // anything to its left is whatever the client chose to send
1029 + $hops = explode(',', (string)Arr::get($serverData, 'HTTP_X_FORWARDED_FOR'));
1030 + $forwardedIp = rest_is_ip_address(trim(end($hops)));
1031 +
1032 + if ($cloudflareIp && self::isCfIp($remoteAddr)) {
1033 + return $cloudflareIp;
1034 + }
1035 +
1036 + if ($forwardedIp && in_array($remoteAddr, $trustedProxies, true)) {
1037 + return $forwardedIp;
1038 + }
1039 +
1040 + return $remoteAddr;
1041 + }
1042 +
1043 + /**
1044 + * Valid E.164 number: 7-15 significant digits.
1045 + *
1046 + * @param string $phone
1047 + * @return bool
1048 + */
1049 + public static function isValidPhoneNumber($phone)
1050 + {
1051 + if (!apply_filters('fluent_booking/enforce_phone_validation', true)) {
1052 + return true;
1053 + }
1054 +
1055 + $phone = trim((string)$phone);
1056 +
1057 + if ($phone === '') {
1058 + return false;
1059 + }
1060 +
1061 + if (!preg_match('/^\+?[0-9\s().\-]+$/', $phone)) {
1062 + return false;
1063 + }
1064 +
1065 + $digitCount = strlen(preg_replace('/\D/', '', $phone));
1066 +
1067 + $min = (int)apply_filters('fluent_booking/phone_min_digits', 7);
1068 + $max = (int)apply_filters('fluent_booking/phone_max_digits', 15);
1069 +
1070 + return $digitCount >= $min && $digitCount <= $max;
1071 + }
1072 +
1073 + private static function isCfIp($ip = '')
1074 + {
1075 + if (!$ip) {
1076 + $serverData = $_SERVER;
1077 + $REMOTE_ADDR = Arr::get($serverData, 'REMOTE_ADDR');
1078 + $ip = $REMOTE_ADDR;
1079 + }
1080 + $cloudflareIPRanges = array(
1081 + '173.245.48.0/20',
1082 + '103.21.244.0/22',
1083 + '103.22.200.0/22',
1084 + '103.31.4.0/22',
1085 + '141.101.64.0/18',
1086 + '108.162.192.0/18',
1087 + '190.93.240.0/20',
1088 + '188.114.96.0/20',
1089 + '197.234.240.0/22',
1090 + '198.41.128.0/17',
1091 + '162.158.0.0/15',
1092 + '104.16.0.0/13',
1093 + '104.24.0.0/14',
1094 + '172.64.0.0/13',
1095 + '131.0.72.0/22',
1096 + );
1097 + //Make sure that the request came via Cloudflare.
1098 + foreach ($cloudflareIPRanges as $range) {
1099 + //Use the ip_in_range function from Joomla.
1100 + if (self::ipInRange($ip, $range)) {
1101 + //IP is valid. Belongs to Cloudflare.
1102 + return true;
956 1103 }
957 1104 }
958 1105
959 - return sanitize_text_field($ip);
1106 + return false;
960 1107 }
961 1108
1109 + private static function ipInRange($ip, $range)
1110 + {
1111 + if (strpos($range, '/') !== false) {
1112 + // $range is in IP/NETMASK format
1113 + list($range, $netmask) = explode('/', $range, 2);
1114 + if (strpos($netmask, '.') !== false) {
1115 + // $netmask is a 255.255.0.0 format
1116 + $netmask = str_replace('*', '0', $netmask);
1117 + $netmask_dec = ip2long($netmask);
1118 + return ((ip2long($ip) & $netmask_dec) == (ip2long($range) & $netmask_dec));
1119 + } else {
1120 + // $netmask is a CIDR size block
1121 + // fix the range argument
1122 + $x = explode('.', $range);
1123 + while (count($x) < 4) $x[] = '0';
1124 + list($a, $b, $c, $d) = $x;
1125 + $range = sprintf("%u.%u.%u.%u", empty($a) ? '0' : $a, empty($b) ? '0' : $b, empty($c) ? '0' : $c, empty($d) ? '0' : $d);
1126 + $range_dec = ip2long($range);
1127 + $ip_dec = ip2long($ip);
1128 +
1129 + # Strategy 1 - Create the netmask with 'netmask' 1s and then fill it to 32 with 0s
1130 + #$netmask_dec = bindec(str_pad('', $netmask, '1') . str_pad('', 32-$netmask, '0'));
1131 +
1132 + # Strategy 2 - Use math to create it
1133 + $wildcard_dec = pow(2, (32 - $netmask)) - 1;
1134 + $netmask_dec = ~$wildcard_dec;
1135 +
1136 + return (($ip_dec & $netmask_dec) == ($range_dec & $netmask_dec));
1137 + }
1138 + } else {
1139 + // range might be 255.255.*.* or 1.2.3.0-1.2.3.255
1140 + if (strpos($range, '*') !== false) { // a.b.*.* format
1141 + // Just convert to A-B format by setting * to 0 for A and 255 for B
1142 + $lower = str_replace('*', '0', $range);
1143 + $upper = str_replace('*', '255', $range);
1144 + $range = "$lower-$upper";
1145 + }
1146 +
1147 + if (strpos($range, '-') !== false) { // A-B format
1148 + list($lower, $upper) = explode('-', $range, 2);
1149 + $lower_dec = (float)sprintf("%u", ip2long($lower));
1150 + $upper_dec = (float)sprintf("%u", ip2long($upper));
1151 + $ip_dec = (float)sprintf("%u", ip2long($ip));
1152 + return (($ip_dec >= $lower_dec) && ($ip_dec <= $upper_dec));
1153 + }
1154 + return false;
1155 + }
1156 + }
1157 +
962 1158 public static function fcal_sanitize_html($html)
963 1159 {
964 1160 if (!$html) {
965 1161 return $html;
@@ -978,9 +1174,8 @@
978 1174 $tags['iframe'] = [
979 1175 'width' => [],
980 1176 'height' => [],
981 1177 'src' => [],
982 - 'srcdoc' => [],
983 1178 'title' => [],
984 1179 'frameborder' => [],
985 1180 'allow' => [],
986 1181 'class' => [],
@@ -988,9 +1183,11 @@
988 1183 'allowfullscreen' => [],
989 1184 'style' => [],
990 1185 ];
991 1186 //button
992 - $tags['button']['onclick'] = [];
1187 + $tags['button'] = [
1188 + 'onclick' => []
1189 + ];
993 1190
994 1191 //svg
995 1192 if (empty($tags['svg'])) {
996 1193 $svg_args = [
@@ -1548,9 +1745,9 @@
1548 1745 'enabled' => true,
1549 1746 'title' => __('Booking Confirmation Email to Attendee', 'fluent-booking'),
1550 1747 'email' => [
1551 1748 'subject' => 'Booking Confirmation between {{host.name}} & {{guest.full_name}}',
1552 - 'body' => '<p style="text-align: center;"><img class="alignnone wp-image-76" src="' . $checkImage . '" alt="" width="60" height="60" /></p><h2 class="p1" style="text-align: center;">Your event has been scheduled</h2><hr /><p><strong>Event Name</strong></p><p>{{booking.event_name}} with {{host.name}}</p><p><strong>When</strong></p><p>{{booking.full_start_end_guest_timezone}}</p><p><strong>Who</strong></p><ul><li>{{host.name}} - Organizer</li><li>{{guest.full_name}} - you</li></ul><p><strong>Where</strong></p><p>{{booking.location_details_html}}</p><p><strong>Additional notes</strong></p><p>{{guest.note}}</p><hr /><p style="text-align: center;">' . __('Need to make a change?', 'fluent-booking') . ' <a href="##booking.reschedule_url##">' . __('Reschedule', 'fluent-booking') . '</a> or <a href="##booking.cancelation_url##">' . __('Cancel', 'fluent-booking') . '</p><hr/>' . self::getAddToCalendarHtml($assetUrl)
1749 + 'body' => '<p style="text-align: center;"><img class="alignnone wp-image-76" src="' . $checkImage . '" alt="" width="60" height="60" /></p><h2 class="p1" style="text-align: center;">Your event has been scheduled</h2><hr /><p><strong>Event Name</strong></p><p>{{booking.event_name}} with {{host.name}}</p><p><strong>When</strong></p><p>{{booking.all_bookings_short_times_guest_timezone}}</p><p><strong>Who</strong></p><ul><li>{{host.name}} - Organizer</li><li>{{guest.full_name}} - you</li></ul><p><strong>Where</strong></p><p>{{booking.location_details_html}}</p><p><strong>Additional notes</strong></p><p>{{guest.note}}</p><hr /><p style="text-align: center;">' . __('Need to make a change?', 'fluent-booking') . ' <a href="##booking.reschedule_url##">' . __('Reschedule', 'fluent-booking') . '</a> or <a href="##booking.cancelation_url##">' . __('Cancel', 'fluent-booking') . '</p><hr/>' . self::getAddToCalendarHtml($assetUrl)
1553 1750 ],
1554 1751 ],
1555 1752 'booking_conf_host' => [
1556 1753 'enabled' => true,
@@ -1558,9 +1755,9 @@
1558 1755 'title' => __('Booking Confirmation Email to Organizer (You)', 'fluent-booking'),
1559 1756 'email' => [
1560 1757 'additional_recipients' => '',
1561 1758 'subject' => 'New Booking: {{guest.full_name}} @ {{booking.start_date_time_for_host}}',
1562 - 'body' => '<p style="text-align: center;"><img class="alignnone wp-image-76" src="' . $checkImage . '" alt="" width="60" height="60" /></p><h2 class="p1" style="text-align: center;">A new event has been scheduled</h2><hr /><p><strong>Event Name</strong></p><p>{{booking.event_name}} with {{guest.full_name}}</p><p><strong>When</strong></p><p>{{booking.full_start_end_host_timezone}}</p><p><strong>Who</strong></p><ul><li>{{host.name}} - Organizer</li><li>{{guest.full_name}} ({{guest.email}}) - Guest</li></ul><p><strong>Where</strong></p><p>{{booking.location_details_html}}</p><p><strong>Note</strong></p><p>{{guest.note}}</p><p><strong>Additional Data</strong></p><p>{{guest.form_data_html}}</p><hr /><p style="text-align: center;"><a href="##booking.admin_booking_url##">View on the Website</a></p>'
1759 + 'body' => '<p style="text-align: center;"><img class="alignnone wp-image-76" src="' . $checkImage . '" alt="" width="60" height="60" /></p><h2 class="p1" style="text-align: center;">A new event has been scheduled</h2><hr /><p><strong>Event Name</strong></p><p>{{booking.event_name}} with {{guest.full_name}}</p><p><strong>When</strong></p><p>{{booking.all_bookings_short_times_host_timezone}}</p><p><strong>Who</strong></p><ul><li>{{host.name}} - Organizer</li><li>{{guest.full_name}} ({{guest.email}}) - Guest</li></ul><p><strong>Where</strong></p><p>{{booking.location_details_html}}</p><p><strong>Note</strong></p><p>{{guest.note}}</p><p><strong>Additional Data</strong></p><p>{{guest.form_data_html}}</p><hr /><p style="text-align: center;"><a href="##booking.admin_booking_url##">View on the Website</a></p>'
1563 1760 ],
1564 1761 ],
1565 1762 'reminder_to_attendee' => [
1566 1763 'enabled' => false,
@@ -1634,9 +1831,9 @@
1634 1831 'title' => __('Booking Approval Request to Host (email to Organizer)', 'fluent-booking'),
1635 1832 'email' => [
1636 1833 'additional_recipients' => '',
1637 1834 'subject' => 'Awaiting Approval: {{guest.full_name}} @ {{booking.start_date_time_for_host}}',
1638 - 'body' => '<p style="text-align: center;"><img class="alignnone wp-image-76" src="' . $scheduleImage . '" alt="" width="60" height="60" /></p><h2 class="p1" style="text-align: center;">A booking is still waiting for your approval</h2><hr /><p>Someone has requested to schedule an event on your calendar. Here are the details:</p><p><strong>Event Name</strong></p><p>{{booking.event_name}} with {{guest.full_name}}</p><p><strong>When</strong></p><p>{{booking.full_start_end_host_timezone}}</p><p><strong>Who</strong></p><ul><li>{{host.name}} - Organizer</li><li>{{guest.full_name}} ({{guest.email}}) - Guest</li></ul><p><strong>Where</strong></p><p>{{booking.location_details_html}}</p><p><strong>Note</strong></p><p>{{guest.note}}</p><p><strong>Additional Data</strong></p><p>{{guest.form_data_html}}</p><hr />' . self::getConfirmAndRejectButton($assetUrl) . '<p style="text-align: center;"><a href="##booking.admin_booking_url##">View on the Website</a></p>'
1835 + 'body' => '<p style="text-align: center;"><img class="alignnone wp-image-76" src="' . $scheduleImage . '" alt="" width="60" height="60" /></p><h2 class="p1" style="text-align: center;">A booking is still waiting for your approval</h2><hr /><p>Someone has requested to schedule an event on your calendar. Here are the details:</p><p><strong>Event Name</strong></p><p>{{booking.event_name}} with {{guest.full_name}}</p><p><strong>When</strong></p><p>{{booking.all_bookings_short_times_host_timezone}}</p><p><strong>Who</strong></p><ul><li>{{host.name}} - Organizer</li><li>{{guest.full_name}} ({{guest.email}}) - Guest</li></ul><p><strong>Where</strong></p><p>{{booking.location_details_html}}</p><p><strong>Note</strong></p><p>{{guest.note}}</p><p><strong>Additional Data</strong></p><p>{{guest.form_data_html}}</p><hr />' . self::getConfirmAndRejectButton($assetUrl) . '<p style="text-align: center;"><a href="##booking.admin_booking_url##">View on the Website</a></p>'
1639 1836 ],
1640 1837 ],
1641 1838 'booking_request_attendee' => [
1642 1839 'enabled' => true,
@@ -1642,9 +1839,9 @@
1642 1839 'enabled' => true,
1643 1840 'title' => __('Booking Submission Confirmation (email to Attendee)', 'fluent-booking'),
1644 1841 'email' => [
1645 1842 'subject' => 'Booking Submitted: Meeting between {{host.name}} & {{guest.full_name}}',
1646 - 'body' => '<p style="text-align: center;"><img class="alignnone wp-image-76" src="' . $scheduleImage . '" alt="" width="60" height="60" /></p><h2 class="p1" style="text-align: center;">Your booking has been submitted</h2><hr /><p>Please wait for the host to confirm your booking.</p><p><strong>Event Name</strong></p><p>{{booking.event_name}} with {{host.name}}</p><p><strong>When</strong></p><p>{{booking.full_start_end_guest_timezone}}</p><p><strong>Who</strong></p><ul><li>{{host.name}} - Organizer</li><li>{{guest.full_name}} - you</li></ul><p><strong>Where</strong></p><p>{{booking.location_details_html}}</p><p><strong>Additional notes</strong></p><p>{{guest.note}}</p><hr /><p style="text-align: center;">' . __('Need to make a change?', 'fluent-booking') . ' <a href="##booking.reschedule_url##">' . __('Reschedule', 'fluent-booking') . '</a> or <a href="##booking.cancelation_url##">' . __('Cancel', 'fluent-booking') . '</p>'
1843 + 'body' => '<p style="text-align: center;"><img class="alignnone wp-image-76" src="' . $scheduleImage . '" alt="" width="60" height="60" /></p><h2 class="p1" style="text-align: center;">Your booking has been submitted</h2><hr /><p>Please wait for the host to confirm your booking.</p><p><strong>Event Name</strong></p><p>{{booking.event_name}} with {{host.name}}</p><p><strong>When</strong></p><p>{{booking.all_bookings_short_times_guest_timezone}}</p><p><strong>Who</strong></p><ul><li>{{host.name}} - Organizer</li><li>{{guest.full_name}} - you</li></ul><p><strong>Where</strong></p><p>{{booking.location_details_html}}</p><p><strong>Additional notes</strong></p><p>{{guest.note}}</p><hr /><p style="text-align: center;">' . __('Need to make a change?', 'fluent-booking') . ' <a href="##booking.reschedule_url##">' . __('Reschedule', 'fluent-booking') . '</a> or <a href="##booking.cancelation_url##">' . __('Cancel', 'fluent-booking') . '</p>'
1647 1844 ],
1648 1845 ],
1649 1846 'declined_by_host' => [
1650 1847 'enabled' => true,
@@ -1711,8 +1908,12 @@
1711 1908 '{{booking.full_start_end_guest_timezone}}' => __('Full Start Date Time (with guest timezone)', 'fluent-booking'),
1712 1909 '{{booking.full_start_end_host_timezone}}' => __('Full Start Date Time (with host timezone)', 'fluent-booking'),
1713 1910 '{{booking.full_start_and_end_guest_timezone}}' => __('Full Start & End Date Time (with guest timezone)', 'fluent-booking'),
1714 1911 '{{booking.full_start_and_end_host_timezone}}' => __('Full Start & End Date Time (with host timezone)', 'fluent-booking'),
1912 + '{{booking.all_bookings_short_times_guest_timezone}}' => __('All Bookings Short Times (with guest timezone)', 'fluent-booking'),
1913 + '{{booking.all_bookings_short_times_host_timezone}}' => __('All Bookings Short Times (with host timezone)', 'fluent-booking'),
1914 + '{{booking.all_bookings_full_times_guest_timezone}}' => __('All Bookings Full Times (with guest timezone)', 'fluent-booking'),
1915 + '{{booking.all_bookings_full_times_host_timezone}}' => __('All Bookings Full Times (with host timezone)', 'fluent-booking'),
1715 1916 '{{booking.start_date_time}}' => __('Event Date Time (UTC)', 'fluent-booking'),
1716 1917 '{{booking.start_date_time_for_attendee}}' => __('Event Date Time (with attendee timezone)', 'fluent-booking'),
1717 1918 '{{booking.start_date_time_for_host}}' => __('Event Date Time (with host timezone)', 'fluent-booking'),
1718 1919 '{{booking.start_date_time_for_attendee.format.Y-m-d}}' => __('Event Date Time (with attendee timezone) (Ex: 2024-05-20)', 'fluent-booking'),
@@ -1722,8 +1923,9 @@
1722 1923 '{{booking.start_time_human_format}}' => __('Event Start Time (ex: 2 hours from now)', 'fluent-booking'),
1723 1924 '##booking.cancelation_url##' => __('Booking Cancellation URL', 'fluent-booking'),
1724 1925 '##booking.reschedule_url##' => __('Booking Reschedule URL', 'fluent-booking'),
1725 1926 '##booking.admin_booking_url##' => __('Booking Details Admin URL', 'fluent-booking'),
1927 + '{{booking.source_url}}' => __('Source URL', 'fluent-booking'),
1726 1928 '{{booking.utm_source}}' => __('UTM Source', 'fluent-booking'),
1727 1929 '{{booking.utm_medium}}' => __('UTM Medium', 'fluent-booking'),
1728 1930 '{{booking.utm_campaign}}' => __('UTM Campaign', 'fluent-booking'),
1729 1931 '{{booking.utm_term}}' => __('UTM Term', 'fluent-booking'),
@@ -1784,11 +1986,15 @@
1784 1986 '{{booking.full_start_end_guest_timezone}}' => __('Full Start Date Time (with guest timezone)', 'fluent-booking'),
1785 1987 '{{booking.full_start_end_host_timezone}}' => __('Full Start Date Time (with host timezone)', 'fluent-booking'),
1786 1988 '{{booking.full_start_and_end_guest_timezone}}' => __('Full Start & End Date Time (with guest timezone)', 'fluent-booking'),
1787 1989 '{{booking.full_start_and_end_host_timezone}}' => __('Full Start & End Date Time (with host timezone)', 'fluent-booking'),
1990 + '{{booking.all_bookings_short_times_guest_timezone}}' => __('All Bookings Short Times (with guest timezone)', 'fluent-booking'),
1991 + '{{booking.all_bookings_short_times_host_timezone}}' => __('All Bookings Short Times (with host timezone)', 'fluent-booking'),
1992 + '{{booking.all_bookings_full_times_guest_timezone}}' => __('All Bookings Full Times (with guest timezone)', 'fluent-booking'),
1993 + '{{booking.all_bookings_full_times_host_timezone}}' => __('All Bookings Full Times (with host timezone)', 'fluent-booking'),
1788 1994 '{{booking.start_date_time}}' => __('Event Date Time (UTC)', 'fluent-booking'),
1789 - '{{booking.start_date_time_for_attendee}}' => __('Event Date time (with guest timezone)', 'fluent-booking'),
1790 - '{{booking.start_date_time_for_host}}' => __('Event Date time (with host timezone)', 'fluent-booking'),
1995 + '{{booking.start_date_time_for_attendee}}' => __('Event Date Time (with guest timezone)', 'fluent-booking'),
1996 + '{{booking.start_date_time_for_host}}' => __('Event Date Time (with host timezone)', 'fluent-booking'),
1791 1997 '{{booking.start_date_time_for_attendee.format.Y-m-d}}' => __('Event Date Time (with attendee timezone) (Ex: 2024-05-20)', 'fluent-booking'),
1792 1998 '{{booking.start_date_time_for_host.format.Y-m-d}}' => __('Event Date Time (with host timezone) (Ex: 2024-05-20)', 'fluent-booking'),
1793 1999 '{{booking.location_details_html}}' => __('Event Location Details (HTML)', 'fluent-booking'),
1794 2000 '{{booking.cancel_reason}}' => __('Event Cancel Reason', 'fluent-booking'),
@@ -1795,8 +2001,9 @@
1795 2001 '{{booking.start_time_human_format}}' => __('Event Start Time (ex: 2 hours from now)', 'fluent-booking'),
1796 2002 '##booking.cancelation_url##' => __('Booking Cancellation URL', 'fluent-booking'),
1797 2003 '##booking.reschedule_url##' => __('Booking Reschedule URL', 'fluent-booking'),
1798 2004 '##booking.admin_booking_url##' => __('Booking Details Admin URL', 'fluent-booking'),
2005 + '{{booking.source_url}}' => __('Source URL', 'fluent-booking'),
1799 2006 '{{booking.utm_source}}' => __('UTM Source', 'fluent-booking'),
1800 2007 '{{booking.utm_medium}}' => __('UTM Medium', 'fluent-booking'),
1801 2008 '{{booking.utm_campaign}}' => __('UTM Campaign', 'fluent-booking'),
1802 2009 '{{booking.utm_term}}' => __('UTM Term', 'fluent-booking'),
@@ -1911,11 +2118,17 @@
1911 2118 return $raw_value;
1912 2119 }
1913 2120
1914 2121 $raw_value = base64_decode($raw_value, true); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
2122 + if ($raw_value === false) {
2123 + return false;
2124 + }
1915 2125
1916 2126 $method = 'aes-256-ctr';
1917 2127 $ivlen = openssl_cipher_iv_length($method);
2128 + if ($ivlen === false || strlen($raw_value) <= $ivlen) {
2129 + return false;
2130 + }
1918 2131 $iv = substr($raw_value, 0, $ivlen);
1919 2132
1920 2133 $raw_value = substr($raw_value, $ivlen);
1921 2134
@@ -1927,9 +2140,9 @@
1927 2140
1928 2141 $salt = (defined('LOGGED_IN_SALT') && '' !== LOGGED_IN_SALT) ? LOGGED_IN_SALT : 'this-is-a-fallback-salt-but-not-secure';
1929 2142
1930 2143 $value = openssl_decrypt($raw_value, $method, $key, 0, $iv);
1931 - if (!$value || substr($value, -strlen($salt)) !== $salt) {
2144 + if (!is_string($value) || substr($value, -strlen($salt)) !== $salt) {
1932 2145 return false;
1933 2146 }
1934 2147
1935 2148 return substr($value, 0, -strlen($salt));
@@ -1966,9 +2179,9 @@
1966 2179 'notification_day' => 'mon',
1967 2180 'start_day' => 'sun',
1968 2181 'auto_cancel_timing' => '10',
1969 2182 'auto_complete_timing' => '60',
1970 - 'default_phone_country' => ''
2183 + 'default_country' => ''
1971 2184 ],
1972 2185 'time_format' => '12',
1973 2186 'theme' => 'system-default'
1974 2187 ];
@@ -2204,8 +2417,103 @@
2204 2417
2205 2418 if ($ins) {
2206 2419 return sanitize_text_field($ins);
2207 2420 }
2208 -
2421 +
2209 2422 return get_option('template');
2423 + }
2424 +
2425 + /**
2426 + * Hold a round robin slot for the rest of the request, so concurrent public
2427 + * bookings cannot pick the same least-loaded host. Locks every host, since
2428 + * the host is only chosen inside isSpotAvailable() and another event can
2429 + * share it. Same keys MCP locks with.
2430 + * Released at shutdown because wp_send_json() exits past any finally.
2431 + *
2432 + * @param CalendarSlot $event
2433 + * @param string $startTimeUtc
2434 + * @param string $endTimeUtc
2435 + *
2436 + * @return bool false when another request holds the slot
2437 + */
2438 + public static function lockRoundRobinSlot($event, $startTimeUtc, $endTimeUtc)
2439 + {
2440 + if (!$event->isRoundRobin()) {
2441 + return true;
2442 + }
2443 +
2444 + $locks = SlotLock::acquireInterval($event->id, $startTimeUtc, $endTimeUtc, $event->getHostIds());
2445 +
2446 + if ($locks) {
2447 + register_shutdown_function([SlotLock::class, 'releaseAll'], $locks);
2448 + }
2449 +
2450 + return (bool) $locks;
2451 + }
2452 +
2453 + /**
2454 + * Per-IP fixed-window rate limiter for public AJAX/REST endpoints.
2455 + *
2456 + * @param string $action Action name (e.g. apply_coupon, schedule_meeting).
2457 + * @param int $limit Max requests per window.
2458 + * @param int $window Window in seconds.
2459 + * @param bool $perIp False for one shared bucket that a spoofed IP cannot reset.
2460 + * @return bool True if under the limit (and the count was incremented),
2461 + * false if over.
2462 + */
2463 + public static function checkRateLimit($action, $limit, $window = 60, $perIp = true)
2464 + {
2465 + $args = apply_filters('fluent_booking/public_ajax_ratelimit', [
2466 + 'limit' => $limit,
2467 + 'window' => $window,
2468 + ], $action);
2469 +
2470 + $limit = max(1, (int) (isset($args['limit']) ? $args['limit'] : $limit));
2471 + $window = max(1, (int) (isset($args['window']) ? $args['window'] : $window));
2472 +
2473 + $key = 'fcal_ratelimit_' . $action . ($perIp ? '_' . md5(self::getIp()) : '');
2474 + $count = (int) get_transient($key);
2475 +
2476 + if ($count >= $limit) {
2477 + return false;
2478 + }
2479 +
2480 + set_transient($key, $count + 1, $window);
2481 +
2482 + return true;
2483 + }
2484 +
2485 + /**
2486 + * Run a callback inside a database transaction, re-throwing on failure so
2487 + * the caller decides how to report it.
2488 + *
2489 + * \Throwable, not \Exception: a TypeError is an Error, and an
2490 + * Exception-only catch would leave the transaction open.
2491 + *
2492 + * Database writes only — a hook fired in here would hold the callback's
2493 + * rows locked for the length of a listener's outbound request.
2494 + *
2495 + * @param callable $callback
2496 + *
2497 + * @return mixed
2498 + *
2499 + * @throws \Throwable after the rollback
2500 + */
2501 + public static function dbTransaction($callback)
2502 + {
2503 + $db = App::getInstance('db');
2504 +
2505 + $db->beginTransaction();
2506 +
2507 + try {
2508 + $result = $callback();
2509 +
2510 + $db->commit();
2511 +
2512 + return $result;
2513 + } catch (\Throwable $e) {
2514 + $db->rollBack();
2515 +
2516 + throw $e;
2517 + }
2210 2518 }
2211 2519 }