| 1 |
<?php |
| 2 |
/** |
| 3 |
* Signed context for native Booking Form AJAX requests. |
| 4 |
* |
| 5 |
* @package Booking Calendar |
| 6 |
*/ |
| 7 |
|
| 8 |
if ( ! defined( 'ABSPATH' ) ) { |
| 9 |
exit; |
| 10 |
} |
| 11 |
|
| 12 |
/** |
| 13 |
* Return the current signed Booking Form context contract version. |
| 14 |
* |
| 15 |
* Version 2 adds a server-authored workflow identity. Rejecting older tokens |
| 16 |
* prevents indefinitely cached pre-fix Appointment or Resource Selector forms |
| 17 |
* from being replayed as unsigned Classic bookings. |
| 18 |
* |
| 19 |
* @return int Current context contract version. |
| 20 |
*/ |
| 21 |
function wpbc_classic_booking_context_get_version() { |
| 22 |
return 2; |
| 23 |
} |
| 24 |
|
| 25 |
/** |
| 26 |
* Normalize one YYYY-MM-DD value and reject impossible calendar dates. |
| 27 |
* |
| 28 |
* @param mixed $date_value Candidate date value. |
| 29 |
* |
| 30 |
* @return string Valid normalized date or an empty string. |
| 31 |
*/ |
| 32 |
function wpbc_classic_booking_context_normalize_date( $date_value ) { |
| 33 |
$date_value = sanitize_text_field( (string) $date_value ); |
| 34 |
if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date_value ) ) { |
| 35 |
return ''; |
| 36 |
} |
| 37 |
|
| 38 |
$date_object = DateTimeImmutable::createFromFormat( '!Y-m-d', $date_value, wp_timezone() ); |
| 39 |
if ( false === $date_object || $date_object->format( 'Y-m-d' ) !== $date_value ) { |
| 40 |
return ''; |
| 41 |
} |
| 42 |
|
| 43 |
return $date_value; |
| 44 |
} |
| 45 |
|
| 46 |
/** |
| 47 |
* Determine whether a Classic shortcode range intentionally starts in the past. |
| 48 |
* |
| 49 |
* The historical Booking Calendar contract treats a past calendar_dates_start |
| 50 |
* value as site-author permission to submit dates from that visible range. |
| 51 |
* |
| 52 |
* @param mixed $calendar_dates_start Inclusive shortcode start date. |
| 53 |
* @param string $today_ymd Optional YYYY-MM-DD comparison date for deterministic callers and tests. |
| 54 |
* |
| 55 |
* @return bool True when the valid range start is earlier than today. |
| 56 |
*/ |
| 57 |
function wpbc_classic_booking_context_should_allow_past( $calendar_dates_start, $today_ymd = '' ) { |
| 58 |
$calendar_dates_start = wpbc_classic_booking_context_normalize_date( $calendar_dates_start ); |
| 59 |
$today_ymd = wpbc_classic_booking_context_normalize_date( $today_ymd ); |
| 60 |
|
| 61 |
if ( '' === $today_ymd ) { |
| 62 |
$today_ymd = current_time( 'Y-m-d' ); |
| 63 |
} |
| 64 |
|
| 65 |
return '' !== $calendar_dates_start && $calendar_dates_start < $today_ymd; |
| 66 |
} |
| 67 |
|
| 68 |
/** |
| 69 |
* Normalize the legacy default Booking Form representation. |
| 70 |
* |
| 71 |
* The standard form does not render a booking_form_type hidden field, so its |
| 72 |
* frontend submission uses an empty string even though the shortcode resolver |
| 73 |
* represents the same form as "standard". |
| 74 |
* |
| 75 |
* @param mixed $custom_form Candidate Booking Form slug. |
| 76 |
* |
| 77 |
* @return string Sanitized Booking Form slug, using "standard" for an omitted value. |
| 78 |
*/ |
| 79 |
function wpbc_classic_booking_context_normalize_form( $custom_form ) { |
| 80 |
$custom_form = sanitize_text_field( (string) $custom_form ); |
| 81 |
|
| 82 |
return '' === $custom_form ? 'standard' : $custom_form; |
| 83 |
} |
| 84 |
|
| 85 |
/** |
| 86 |
* Normalize additional aggregate Booking Resource IDs for signed contexts. |
| 87 |
* |
| 88 |
* The legacy shortcode renderer represents an aggregate form as the primary |
| 89 |
* Resource followed by its additional Resources. Calendar runtime state |
| 90 |
* intentionally stores only the additional Resources because the primary is |
| 91 |
* already carried separately as resource_id. Removing that separately bound |
| 92 |
* primary gives both established shapes one canonical representation while |
| 93 |
* preserving an exact-set security comparison for every additional Resource. |
| 94 |
* |
| 95 |
* @param array|string|int $aggregate_resource_ids Candidate Resource IDs. |
| 96 |
* @param mixed $primary_resource_id Separately bound primary Resource ID. |
| 97 |
* |
| 98 |
* @return int[] Sorted unique positive IDs excluding the primary Resource. |
| 99 |
*/ |
| 100 |
function wpbc_classic_booking_context_normalize_aggregate_resource_ids( $aggregate_resource_ids, $primary_resource_id = 0 ) { |
| 101 |
$aggregate_resource_ids = is_array( $aggregate_resource_ids ) ? $aggregate_resource_ids : array( $aggregate_resource_ids ); |
| 102 |
$primary_resource_id = absint( $primary_resource_id ); |
| 103 |
$normalized_resource_ids = array(); |
| 104 |
|
| 105 |
foreach ( $aggregate_resource_ids as $aggregate_resource_id ) { |
| 106 |
if ( ! is_int( $aggregate_resource_id ) && ! is_float( $aggregate_resource_id ) && ! is_string( $aggregate_resource_id ) ) { |
| 107 |
continue; |
| 108 |
} |
| 109 |
|
| 110 |
$resource_id_parts = preg_split( '/[;,\s]+/', (string) $aggregate_resource_id, -1, PREG_SPLIT_NO_EMPTY ); |
| 111 |
foreach ( (array) $resource_id_parts as $resource_id_part ) { |
| 112 |
$resource_id = absint( $resource_id_part ); |
| 113 |
if ( ! $resource_id || $resource_id === $primary_resource_id ) { |
| 114 |
continue; |
| 115 |
} |
| 116 |
|
| 117 |
$normalized_resource_ids[ $resource_id ] = $resource_id; |
| 118 |
} |
| 119 |
} |
| 120 |
|
| 121 |
$normalized_resource_ids = array_values( $normalized_resource_ids ); |
| 122 |
sort( $normalized_resource_ids, SORT_NUMERIC ); |
| 123 |
|
| 124 |
return $normalized_resource_ids; |
| 125 |
} |
| 126 |
|
| 127 |
/** |
| 128 |
* Normalize the native Booking Form context before it is signed or consumed. |
| 129 |
* |
| 130 |
* @param mixed $context Raw context values. |
| 131 |
* |
| 132 |
* @return array<string,mixed> Stable context contract. |
| 133 |
*/ |
| 134 |
function wpbc_classic_booking_context_normalize( $context ) { |
| 135 |
$context = is_array( $context ) ? $context : array(); |
| 136 |
$context = wp_parse_args( |
| 137 |
$context, |
| 138 |
array( |
| 139 |
'context_version' => 0, |
| 140 |
'booking_workflow' => 'classic', |
| 141 |
'resource_id' => 0, |
| 142 |
'calendar_dates_start' => '', |
| 143 |
'calendar_dates_end' => '', |
| 144 |
'custom_form' => 'standard', |
| 145 |
'aggregate_resource_ids' => array(), |
| 146 |
'allow_past' => false, |
| 147 |
) |
| 148 |
); |
| 149 |
$calendar_dates_start = wpbc_classic_booking_context_normalize_date( $context['calendar_dates_start'] ); |
| 150 |
$resource_id = absint( $context['resource_id'] ); |
| 151 |
$aggregate_resource_ids = wpbc_classic_booking_context_normalize_aggregate_resource_ids( $context['aggregate_resource_ids'], $resource_id ); |
| 152 |
$booking_workflow = sanitize_key( (string) $context['booking_workflow'] ); |
| 153 |
if ( ! in_array( $booking_workflow, array( 'classic', 'appointment', 'resource_selector' ), true ) ) { |
| 154 |
$booking_workflow = 'classic'; |
| 155 |
} |
| 156 |
|
| 157 |
// Derive permission from the site-authored date boundary; never trust a caller-supplied allow_past flag. |
| 158 |
return array( |
| 159 |
'context_version' => absint( $context['context_version'] ), |
| 160 |
'booking_workflow' => $booking_workflow, |
| 161 |
'resource_id' => $resource_id, |
| 162 |
'calendar_dates_start' => $calendar_dates_start, |
| 163 |
'calendar_dates_end' => wpbc_classic_booking_context_normalize_date( $context['calendar_dates_end'] ), |
| 164 |
'custom_form' => wpbc_classic_booking_context_normalize_form( $context['custom_form'] ), |
| 165 |
'aggregate_resource_ids' => $aggregate_resource_ids, |
| 166 |
'allow_past' => wpbc_classic_booking_context_should_allow_past( $calendar_dates_start ), |
| 167 |
); |
| 168 |
} |
| 169 |
|
| 170 |
/** |
| 171 |
* Base64-url encode a context value without padding. |
| 172 |
* |
| 173 |
* @param string $context_value Value to encode. |
| 174 |
* |
| 175 |
* @return string URL-safe encoded value. |
| 176 |
*/ |
| 177 |
function wpbc_classic_booking_context_base64url_encode( $context_value ) { |
| 178 |
return rtrim( strtr( base64_encode( (string) $context_value ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode |
| 179 |
} |
| 180 |
|
| 181 |
/** |
| 182 |
* Decode one strict base64-url context value. |
| 183 |
* |
| 184 |
* @param string $encoded_value Encoded value. |
| 185 |
* |
| 186 |
* @return string|false Decoded value or false when malformed. |
| 187 |
*/ |
| 188 |
function wpbc_classic_booking_context_base64url_decode( $encoded_value ) { |
| 189 |
$encoded_value = strtr( (string) $encoded_value, '-_', '+/' ); |
| 190 |
$padding = strlen( $encoded_value ) % 4; |
| 191 |
if ( $padding ) { |
| 192 |
$encoded_value .= str_repeat( '=', 4 - $padding ); |
| 193 |
} |
| 194 |
|
| 195 |
return base64_decode( $encoded_value, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode |
| 196 |
} |
| 197 |
|
| 198 |
/** |
| 199 |
* Sign a normalized native Booking Form context for cache-safe AJAX round trips. |
| 200 |
* |
| 201 |
* The HMAC has no time component, so cached front-end pages remain usable until |
| 202 |
* WordPress authentication salts change. No secret or raw signature key is |
| 203 |
* exposed to the browser. |
| 204 |
* |
| 205 |
* @param mixed $context Raw or normalized context. |
| 206 |
* |
| 207 |
* @return string Signed opaque token, or an empty string for invalid context. |
| 208 |
*/ |
| 209 |
function wpbc_classic_booking_context_encode( $context ) { |
| 210 |
$context = is_array( $context ) ? $context : array(); |
| 211 |
$context['context_version'] = wpbc_classic_booking_context_get_version(); |
| 212 |
$context = wpbc_classic_booking_context_normalize( $context ); |
| 213 |
if ( |
| 214 |
0 === $context['resource_id'] |
| 215 |
|| ( ( '' === $context['calendar_dates_start'] ) !== ( '' === $context['calendar_dates_end'] ) ) |
| 216 |
|| ( |
| 217 |
'' !== $context['calendar_dates_start'] |
| 218 |
&& $context['calendar_dates_start'] > $context['calendar_dates_end'] |
| 219 |
) |
| 220 |
) { |
| 221 |
return ''; |
| 222 |
} |
| 223 |
|
| 224 |
$payload = wpbc_classic_booking_context_base64url_encode( wp_json_encode( $context ) ); |
| 225 |
$signature = hash_hmac( 'sha256', $payload, wp_salt( 'auth' ), true ); |
| 226 |
|
| 227 |
return $payload . '.' . wpbc_classic_booking_context_base64url_encode( $signature ); |
| 228 |
} |
| 229 |
|
| 230 |
/** |
| 231 |
* Verify and decode a signed native Booking Form context token. |
| 232 |
* |
| 233 |
* @param string $context_token Signed token received through AJAX. |
| 234 |
* |
| 235 |
* @return array<string,mixed>|WP_Error Normalized context or a safe validation error. |
| 236 |
*/ |
| 237 |
function wpbc_classic_booking_context_decode( $context_token ) { |
| 238 |
$token_parts = explode( '.', (string) $context_token, 2 ); |
| 239 |
if ( 2 !== count( $token_parts ) ) { |
| 240 |
return new WP_Error( 'classic_booking_context_invalid', __( 'The booking form context could not be verified. Please reload the page and try again.', 'booking' ) ); |
| 241 |
} |
| 242 |
|
| 243 |
$expected_signature = hash_hmac( 'sha256', $token_parts[0], wp_salt( 'auth' ), true ); |
| 244 |
$actual_signature = wpbc_classic_booking_context_base64url_decode( $token_parts[1] ); |
| 245 |
if ( false === $actual_signature || ! hash_equals( $expected_signature, $actual_signature ) ) { |
| 246 |
return new WP_Error( 'classic_booking_context_invalid', __( 'The booking form context could not be verified. Please reload the page and try again.', 'booking' ) ); |
| 247 |
} |
| 248 |
|
| 249 |
$context_json = wpbc_classic_booking_context_base64url_decode( $token_parts[0] ); |
| 250 |
$context = false !== $context_json ? json_decode( $context_json, true ) : null; |
| 251 |
if ( ! is_array( $context ) ) { |
| 252 |
return new WP_Error( 'classic_booking_context_invalid', __( 'The booking form context could not be verified. Please reload the page and try again.', 'booking' ) ); |
| 253 |
} |
| 254 |
|
| 255 |
$context = wpbc_classic_booking_context_normalize( $context ); |
| 256 |
if ( wpbc_classic_booking_context_get_version() !== $context['context_version'] ) { |
| 257 |
return new WP_Error( 'classic_booking_context_expired', __( 'The booking form context has expired. Please reload the page and try again.', 'booking' ) ); |
| 258 |
} |
| 259 |
if ( |
| 260 |
0 === $context['resource_id'] |
| 261 |
|| ( ( '' === $context['calendar_dates_start'] ) !== ( '' === $context['calendar_dates_end'] ) ) |
| 262 |
|| ( |
| 263 |
'' !== $context['calendar_dates_start'] |
| 264 |
&& $context['calendar_dates_start'] > $context['calendar_dates_end'] |
| 265 |
) |
| 266 |
) { |
| 267 |
return new WP_Error( 'classic_booking_context_invalid', __( 'The booking form context could not be verified. Please reload the page and try again.', 'booking' ) ); |
| 268 |
} |
| 269 |
|
| 270 |
return $context; |
| 271 |
} |
| 272 |
|
| 273 |
/** |
| 274 |
* Validate a Booking Form AJAX request against its signed server-rendered boundaries. |
| 275 |
* |
| 276 |
* @param string $context_token Signed Booking Form context token. |
| 277 |
* @param mixed $resource_id Submitted primary Booking Resource ID. |
| 278 |
* @param array|string $submitted_dates Submitted YYYY-MM-DD dates. |
| 279 |
* @param string $custom_form Submitted Booking Form identifier. |
| 280 |
* @param array|string $aggregate_resource_ids Submitted aggregate Booking Resource IDs. |
| 281 |
* |
| 282 |
* @return array<string,mixed>|WP_Error Verified context or a validation error. |
| 283 |
*/ |
| 284 |
function wpbc_classic_booking_context_validate_submission( $context_token, $resource_id, $submitted_dates, $custom_form = 'standard', $aggregate_resource_ids = array() ) { |
| 285 |
$context = wpbc_classic_booking_context_decode( $context_token ); |
| 286 |
if ( is_wp_error( $context ) ) { |
| 287 |
return $context; |
| 288 |
} |
| 289 |
|
| 290 |
if ( absint( $resource_id ) !== $context['resource_id'] ) { |
| 291 |
return new WP_Error( 'classic_booking_context_resource_mismatch', __( 'The selected booking resource does not match this booking form. Please reload the page and try again.', 'booking' ) ); |
| 292 |
} |
| 293 |
|
| 294 |
$custom_form = wpbc_classic_booking_context_normalize_form( $custom_form ); |
| 295 |
if ( $custom_form !== $context['custom_form'] ) { |
| 296 |
return new WP_Error( 'classic_booking_context_form_mismatch', __( 'The selected Booking Form does not match this calendar. Please reload the page and try again.', 'booking' ) ); |
| 297 |
} |
| 298 |
|
| 299 |
$aggregate_resource_ids = wpbc_classic_booking_context_normalize_aggregate_resource_ids( $aggregate_resource_ids, $context['resource_id'] ); |
| 300 |
if ( $aggregate_resource_ids !== $context['aggregate_resource_ids'] ) { |
| 301 |
$troubleshooting_url = 'https://wpbookingcalendar.com/faq/troubleshooting-the-booking-resources-do-not-match-this-calendar/'; |
| 302 |
$aggregate_mismatch_message = esc_html__( 'The booking resources do not match this calendar. Please reload the page and try again.', 'booking' ); |
| 303 |
$aggregate_mismatch_message .= sprintf( |
| 304 |
'<br><a href="%1$s" target="_blank" rel="noopener noreferrer">%2$s</a>', |
| 305 |
esc_url( $troubleshooting_url ), |
| 306 |
esc_html__( 'Open the troubleshooting guide.', 'booking' ) |
| 307 |
); |
| 308 |
|
| 309 |
return new WP_Error( 'classic_booking_context_aggregate_mismatch', $aggregate_mismatch_message ); |
| 310 |
} |
| 311 |
|
| 312 |
if ( is_string( $submitted_dates ) ) { |
| 313 |
$submitted_dates = preg_split( '/\s*,\s*/', $submitted_dates, -1, PREG_SPLIT_NO_EMPTY ); |
| 314 |
} |
| 315 |
$submitted_dates = array_values( (array) $submitted_dates ); |
| 316 |
if ( empty( $submitted_dates ) ) { |
| 317 |
return new WP_Error( 'classic_booking_context_date_invalid', __( 'The selected booking date is invalid. Please select the date again.', 'booking' ) ); |
| 318 |
} |
| 319 |
|
| 320 |
foreach ( $submitted_dates as $submitted_date ) { |
| 321 |
$submitted_date = wpbc_classic_booking_context_normalize_date( $submitted_date ); |
| 322 |
if ( '' === $submitted_date ) { |
| 323 |
return new WP_Error( 'classic_booking_context_date_invalid', __( 'The selected booking date is invalid. Please select the date again.', 'booking' ) ); |
| 324 |
} |
| 325 |
if ( |
| 326 |
'' !== $context['calendar_dates_start'] |
| 327 |
&& ( $submitted_date < $context['calendar_dates_start'] || $submitted_date > $context['calendar_dates_end'] ) |
| 328 |
) { |
| 329 |
return new WP_Error( 'classic_booking_context_date_outside_range', __( 'The selected booking date is outside this calendar range. Please select another date.', 'booking' ) ); |
| 330 |
} |
| 331 |
} |
| 332 |
|
| 333 |
return $context; |
| 334 |
} |
| 335 |
|