| @@ -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 | } |