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 +104 -37 2.2.5 → 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 {
@@ -998,45 +999,46 @@
998 999 // It's a local cli request
999 1000 return $defalt;
1000 1001 }
1001 1002
1002 - $ipAddress = '';
1003 + $ipAddress = self::resolveClientIp($_SERVER);
1003 1004
1004 - $serverData = $_SERVER;
1005 - $HTTP_CF_CONNECTING_IP = Arr::get($serverData, 'HTTP_CF_CONNECTING_IP');
1006 - $RemoteAddr = Arr::get($serverData, 'REMOTE_ADDR');
1007 - $clientIp = Arr::get($serverData, 'HTTP_CLIENT_IP');
1008 - $HTTP_X_FORWARDED_FOR = Arr::get($serverData, 'HTTP_X_FORWARDED_FOR');
1009 - if ($HTTP_CF_CONNECTING_IP) {
1010 - //If it's a valid Cloudflare request
1005 + $ipAddress = apply_filters('fluent_booking/user_ip', $ipAddress, []);
1011 1006
1012 - if (self::isCfIp($RemoteAddr)) {
1013 - //Use the CF-Connecting-IP header.
1014 - $ipAddress = $HTTP_CF_CONNECTING_IP;
1015 - } else {
1016 - //If it isn't valid, then use REMOTE_ADDR.
1017 - $ipAddress = $RemoteAddr;
1018 - }
1019 - } else if ($RemoteAddr == '127.0.0.1') {
1020 - // most probably it's local reverse proxy
1021 - if ($clientIp) {
1022 - $ipAddress = $clientIp;
1023 - } else if ($HTTP_X_FORWARDED_FOR) {
1024 - $ipAddress = (string)rest_is_ip_address(trim(current(preg_split('/,/', sanitize_text_field($HTTP_X_FORWARDED_FOR)))));
1025 - }
1026 - }
1007 + $ipAddress = sanitize_text_field(wp_unslash($ipAddress));
1027 1008
1028 - if (!$ipAddress) {
1029 - $ipAddress = $RemoteAddr;
1030 - }
1009 + return $ipAddress;
1010 + }
1031 1011
1032 - $ipAddress = preg_replace('/^(\d+\.\d+\.\d+\.\d+):\d+$/', '\1', $ipAddress);
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']);
1033 1026
1034 - $ipAddress = apply_filters('fluent_booking/user_ip', $ipAddress, []);
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)));
1035 1031
1036 - $ipAddress = sanitize_text_field(wp_unslash($ipAddress));
1032 + if ($cloudflareIp && self::isCfIp($remoteAddr)) {
1033 + return $cloudflareIp;
1034 + }
1037 1035
1038 - return $ipAddress;
1036 + if ($forwardedIp && in_array($remoteAddr, $trustedProxies, true)) {
1037 + return $forwardedIp;
1038 + }
1039 +
1040 + return $remoteAddr;
1039 1041 }
1040 1042
1041 1043 /**
1042 1044 * Valid E.164 number: 7-15 significant digits.
@@ -1743,9 +1745,9 @@
1743 1745 'enabled' => true,
1744 1746 'title' => __('Booking Confirmation Email to Attendee', 'fluent-booking'),
1745 1747 'email' => [
1746 1748 'subject' => 'Booking Confirmation between {{host.name}} & {{guest.full_name}}',
1747 - '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)
1748 1750 ],
1749 1751 ],
1750 1752 'booking_conf_host' => [
1751 1753 'enabled' => true,
@@ -1753,9 +1755,9 @@
1753 1755 'title' => __('Booking Confirmation Email to Organizer (You)', 'fluent-booking'),
1754 1756 'email' => [
1755 1757 'additional_recipients' => '',
1756 1758 'subject' => 'New Booking: {{guest.full_name}} @ {{booking.start_date_time_for_host}}',
1757 - '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>'
1758 1760 ],
1759 1761 ],
1760 1762 'reminder_to_attendee' => [
1761 1763 'enabled' => false,
@@ -1829,9 +1831,9 @@
1829 1831 'title' => __('Booking Approval Request to Host (email to Organizer)', 'fluent-booking'),
1830 1832 'email' => [
1831 1833 'additional_recipients' => '',
1832 1834 'subject' => 'Awaiting Approval: {{guest.full_name}} @ {{booking.start_date_time_for_host}}',
1833 - '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>'
1834 1836 ],
1835 1837 ],
1836 1838 'booking_request_attendee' => [
1837 1839 'enabled' => true,
@@ -1837,9 +1839,9 @@
1837 1839 'enabled' => true,
1838 1840 'title' => __('Booking Submission Confirmation (email to Attendee)', 'fluent-booking'),
1839 1841 'email' => [
1840 1842 'subject' => 'Booking Submitted: Meeting between {{host.name}} & {{guest.full_name}}',
1841 - '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>'
1842 1844 ],
1843 1845 ],
1844 1846 'declined_by_host' => [
1845 1847 'enabled' => true,
@@ -1968,9 +1970,10 @@
1968 1970 '{{guest.email}}' => __('Guest Email', 'fluent-booking'),
1969 1971 '{{booking.phone}}' => __('Guest Main Phone Number (if provided)', 'fluent-booking'),
1970 1972 '{{guest.note}}' => __('Guest Note', 'fluent-booking'),
1971 1973 '{{guest.timezone}}' => __('Guest Timezone', 'fluent-booking'),
1972 - '{{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')
1973 1976 ]
1974 1977 ],
1975 1978 'booking' => [
1976 1979 'title' => __('Booking Data', 'fluent-booking'),
@@ -2419,17 +2422,46 @@
2419 2422 return get_option('template');
2420 2423 }
2421 2424
2422 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 + /**
2423 2454 * Per-IP fixed-window rate limiter for public AJAX/REST endpoints.
2424 2455 *
2425 2456 * @param string $action Action name (e.g. apply_coupon, schedule_meeting).
2426 2457 * @param int $limit Max requests per window.
2427 2458 * @param int $window Window in seconds.
2459 + * @param bool $perIp False for one shared bucket that a spoofed IP cannot reset.
2428 2460 * @return bool True if under the limit (and the count was incremented),
2429 2461 * false if over.
2430 2462 */
2431 - public static function checkRateLimit($action, $limit, $window = 60)
2463 + public static function checkRateLimit($action, $limit, $window = 60, $perIp = true)
2432 2464 {
2433 2465 $args = apply_filters('fluent_booking/public_ajax_ratelimit', [
2434 2466 'limit' => $limit,
2435 2467 'window' => $window,
@@ -2437,9 +2469,9 @@
2437 2469
2438 2470 $limit = max(1, (int) (isset($args['limit']) ? $args['limit'] : $limit));
2439 2471 $window = max(1, (int) (isset($args['window']) ? $args['window'] : $window));
2440 2472
2441 - $key = 'fcal_ratelimit_' . $action . '_' . md5(self::getIp());
2473 + $key = 'fcal_ratelimit_' . $action . ($perIp ? '_' . md5(self::getIp()) : '');
2442 2474 $count = (int) get_transient($key);
2443 2475
2444 2476 if ($count >= $limit) {
2445 2477 return false;
@@ -2447,6 +2479,41 @@
2447 2479
2448 2480 set_transient($key, $count + 1, $window);
2449 2481
2450 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 + }
2451 2518 }
2452 2519 }