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