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 +167 -37 2.2.0 → 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 {
@@ -965,45 +999,76 @@
965 999 // It's a local cli request
966 1000 return $defalt;
967 1001 }
968 1002
969 - $ipAddress = '';
1003 + $ipAddress = self::resolveClientIp($_SERVER);
970 1004
971 - $serverData = $_SERVER;
972 - $HTTP_CF_CONNECTING_IP = Arr::get($serverData, 'HTTP_CF_CONNECTING_IP');
973 - $RemoteAddr = Arr::get($serverData, 'REMOTE_ADDR');
974 - $clientIp = Arr::get($serverData, 'HTTP_CLIENT_IP');
975 - $HTTP_X_FORWARDED_FOR = Arr::get($serverData, 'HTTP_X_FORWARDED_FOR');
976 - if ($HTTP_CF_CONNECTING_IP) {
977 - //If it's a valid Cloudflare request
1005 + $ipAddress = apply_filters('fluent_booking/user_ip', $ipAddress, []);
978 1006
979 - if (self::isCfIp($RemoteAddr)) {
980 - //Use the CF-Connecting-IP header.
981 - $ipAddress = $HTTP_CF_CONNECTING_IP;
982 - } else {
983 - //If it isn't valid, then use REMOTE_ADDR.
984 - $ipAddress = $RemoteAddr;
985 - }
986 - } else if ($RemoteAddr == '127.0.0.1') {
987 - // most probably it's local reverse proxy
988 - if ($clientIp) {
989 - $ipAddress = $clientIp;
990 - } else if ($HTTP_X_FORWARDED_FOR) {
991 - $ipAddress = (string)rest_is_ip_address(trim(current(preg_split('/,/', sanitize_text_field($HTTP_X_FORWARDED_FOR)))));
992 - }
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;
993 1034 }
994 1035
995 - if (!$ipAddress) {
996 - $ipAddress = $RemoteAddr;
1036 + if ($forwardedIp && in_array($remoteAddr, $trustedProxies, true)) {
1037 + return $forwardedIp;
997 1038 }
998 1039
999 - $ipAddress = preg_replace('/^(\d+\.\d+\.\d+\.\d+):\d+$/', '\1', $ipAddress);
1040 + return $remoteAddr;
1041 + }
1000 1042
1001 - $ipAddress = apply_filters('fluent_booking/user_ip', $ipAddress, []);
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 + }
1002 1054
1003 - $ipAddress = sanitize_text_field(wp_unslash($ipAddress));
1055 + $phone = trim((string)$phone);
1004 1056
1005 - return $ipAddress;
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;
1006 1071 }
1007 1072
1008 1073 private static function isCfIp($ip = '')
1009 1074 {
@@ -1680,9 +1745,9 @@
1680 1745 'enabled' => true,
1681 1746 'title' => __('Booking Confirmation Email to Attendee', 'fluent-booking'),
1682 1747 'email' => [
1683 1748 'subject' => 'Booking Confirmation between {{host.name}} & {{guest.full_name}}',
1684 - '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)
1685 1750 ],
1686 1751 ],
1687 1752 'booking_conf_host' => [
1688 1753 'enabled' => true,
@@ -1690,9 +1755,9 @@
1690 1755 'title' => __('Booking Confirmation Email to Organizer (You)', 'fluent-booking'),
1691 1756 'email' => [
1692 1757 'additional_recipients' => '',
1693 1758 'subject' => 'New Booking: {{guest.full_name}} @ {{booking.start_date_time_for_host}}',
1694 - '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>'
1695 1760 ],
1696 1761 ],
1697 1762 'reminder_to_attendee' => [
1698 1763 'enabled' => false,
@@ -1766,9 +1831,9 @@
1766 1831 'title' => __('Booking Approval Request to Host (email to Organizer)', 'fluent-booking'),
1767 1832 'email' => [
1768 1833 'additional_recipients' => '',
1769 1834 'subject' => 'Awaiting Approval: {{guest.full_name}} @ {{booking.start_date_time_for_host}}',
1770 - '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>'
1771 1836 ],
1772 1837 ],
1773 1838 'booking_request_attendee' => [
1774 1839 'enabled' => true,
@@ -1774,9 +1839,9 @@
1774 1839 'enabled' => true,
1775 1840 'title' => __('Booking Submission Confirmation (email to Attendee)', 'fluent-booking'),
1776 1841 'email' => [
1777 1842 'subject' => 'Booking Submitted: Meeting between {{host.name}} & {{guest.full_name}}',
1778 - '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>'
1779 1844 ],
1780 1845 ],
1781 1846 'declined_by_host' => [
1782 1847 'enabled' => true,
@@ -1905,9 +1970,10 @@
1905 1970 '{{guest.email}}' => __('Guest Email', 'fluent-booking'),
1906 1971 '{{booking.phone}}' => __('Guest Main Phone Number (if provided)', 'fluent-booking'),
1907 1972 '{{guest.note}}' => __('Guest Note', 'fluent-booking'),
1908 1973 '{{guest.timezone}}' => __('Guest Timezone', 'fluent-booking'),
1909 - '{{guest.total_guest}}' => __('Total Guest Count', 'fluent-booking')
1974 + '{{guest.total_guest}}' => __('Total Guest Count', 'fluent-booking'),
1975 + '{{guest.form_data_html}}' => __('Guest Form Submitted Data (HTML)', 'fluent-booking')
1910 1976 ]
1911 1977 ],
1912 1978 'booking' => [
1913 1979 'title' => __('Booking Data', 'fluent-booking'),
@@ -2356,17 +2422,46 @@
2356 2422 return get_option('template');
2357 2423 }
2358 2424
2359 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 + /**
2360 2454 * Per-IP fixed-window rate limiter for public AJAX/REST endpoints.
2361 2455 *
2362 2456 * @param string $action Action name (e.g. apply_coupon, schedule_meeting).
2363 2457 * @param int $limit Max requests per window.
2364 2458 * @param int $window Window in seconds.
2459 + * @param bool $perIp False for one shared bucket that a spoofed IP cannot reset.
2365 2460 * @return bool True if under the limit (and the count was incremented),
2366 2461 * false if over.
2367 2462 */
2368 - public static function checkRateLimit($action, $limit, $window = 60)
2463 + public static function checkRateLimit($action, $limit, $window = 60, $perIp = true)
2369 2464 {
2370 2465 $args = apply_filters('fluent_booking/public_ajax_ratelimit', [
2371 2466 'limit' => $limit,
2372 2467 'window' => $window,
@@ -2374,9 +2469,9 @@
2374 2469
2375 2470 $limit = max(1, (int) (isset($args['limit']) ? $args['limit'] : $limit));
2376 2471 $window = max(1, (int) (isset($args['window']) ? $args['window'] : $window));
2377 2472
2378 - $key = 'fcal_ratelimit_' . $action . '_' . md5(self::getIp());
2473 + $key = 'fcal_ratelimit_' . $action . ($perIp ? '_' . md5(self::getIp()) : '');
2379 2474 $count = (int) get_transient($key);
2380 2475
2381 2476 if ($count >= $limit) {
2382 2477 return false;
@@ -2384,6 +2479,41 @@
2384 2479
2385 2480 set_transient($key, $count + 1, $window);
2386 2481
2387 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 + }
2388 2518 }
2389 2519 }