| 1 |
<?php |
| 2 |
/** |
| 3 |
* Signed context for Classic Booking Calendar shortcode AJAX requests. |
| 4 |
* |
| 5 |
* @package Booking Calendar |
| 6 |
*/ |
| 7 |
|
| 8 |
if ( ! defined( 'ABSPATH' ) ) { |
| 9 |
exit; |
| 10 |
} |
| 11 |
|
| 12 |
/** |
| 13 |
* Normalize one YYYY-MM-DD value and reject impossible calendar dates. |
| 14 |
* |
| 15 |
* @param mixed $date_value Candidate date value. |
| 16 |
* |
| 17 |
* @return string Valid normalized date or an empty string. |
| 18 |
*/ |
| 19 |
function wpbc_classic_booking_context_normalize_date( $date_value ) { |
| 20 |
$date_value = sanitize_text_field( (string) $date_value ); |
| 21 |
if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date_value ) ) { |
| 22 |
return ''; |
| 23 |
} |
| 24 |
|
| 25 |
$date_object = DateTimeImmutable::createFromFormat( '!Y-m-d', $date_value, wp_timezone() ); |
| 26 |
if ( false === $date_object || $date_object->format( 'Y-m-d' ) !== $date_value ) { |
| 27 |
return ''; |
| 28 |
} |
| 29 |
|
| 30 |
return $date_value; |
| 31 |
} |
| 32 |
|
| 33 |
/** |
| 34 |
* Determine whether a Classic shortcode range intentionally starts in the past. |
| 35 |
* |
| 36 |
* The historical Booking Calendar contract treats a past calendar_dates_start |
| 37 |
* value as site-author permission to submit dates from that visible range. |
| 38 |
* |
| 39 |
* @param mixed $calendar_dates_start Inclusive shortcode start date. |
| 40 |
* @param string $today_ymd Optional YYYY-MM-DD comparison date for deterministic callers and tests. |
| 41 |
* |
| 42 |
* @return bool True when the valid range start is earlier than today. |
| 43 |
*/ |
| 44 |
function wpbc_classic_booking_context_should_allow_past( $calendar_dates_start, $today_ymd = '' ) { |
| 45 |
$calendar_dates_start = wpbc_classic_booking_context_normalize_date( $calendar_dates_start ); |
| 46 |
$today_ymd = wpbc_classic_booking_context_normalize_date( $today_ymd ); |
| 47 |
|
| 48 |
if ( '' === $today_ymd ) { |
| 49 |
$today_ymd = current_time( 'Y-m-d' ); |
| 50 |
} |
| 51 |
|
| 52 |
return '' !== $calendar_dates_start && $calendar_dates_start < $today_ymd; |
| 53 |
} |
| 54 |
|
| 55 |
/** |
| 56 |
* Normalize the legacy default Booking Form representation. |
| 57 |
* |
| 58 |
* The standard form does not render a booking_form_type hidden field, so its |
| 59 |
* frontend submission uses an empty string even though the shortcode resolver |
| 60 |
* represents the same form as "standard". |
| 61 |
* |
| 62 |
* @param mixed $custom_form Candidate Booking Form slug. |
| 63 |
* |
| 64 |
* @return string Sanitized Booking Form slug, using "standard" for an omitted value. |
| 65 |
*/ |
| 66 |
function wpbc_classic_booking_context_normalize_form( $custom_form ) { |
| 67 |
$custom_form = sanitize_text_field( (string) $custom_form ); |
| 68 |
|
| 69 |
return '' === $custom_form ? 'standard' : $custom_form; |
| 70 |
} |
| 71 |
|
| 72 |
/** |
| 73 |
* Normalize Classic shortcode context before it is signed or consumed. |
| 74 |
* |
| 75 |
* @param mixed $context Raw context values. |
| 76 |
* |
| 77 |
* @return array<string,mixed> Stable context contract. |
| 78 |
*/ |
| 79 |
function wpbc_classic_booking_context_normalize( $context ) { |
| 80 |
$context = is_array( $context ) ? $context : array(); |
| 81 |
$context = wp_parse_args( |
| 82 |
$context, |
| 83 |
array( |
| 84 |
'resource_id' => 0, |
| 85 |
'calendar_dates_start' => '', |
| 86 |
'calendar_dates_end' => '', |
| 87 |
'custom_form' => 'standard', |
| 88 |
'aggregate_resource_ids' => array(), |
| 89 |
'allow_past' => false, |
| 90 |
) |
| 91 |
); |
| 92 |
$calendar_dates_start = wpbc_classic_booking_context_normalize_date( $context['calendar_dates_start'] ); |
| 93 |
|
| 94 |
$aggregate_resource_ids = array_values( array_unique( array_filter( array_map( 'absint', (array) $context['aggregate_resource_ids'] ) ) ) ); |
| 95 |
sort( $aggregate_resource_ids, SORT_NUMERIC ); |
| 96 |
|
| 97 |
// Derive permission from the site-authored date boundary; never trust a caller-supplied allow_past flag. |
| 98 |
return array( |
| 99 |
'resource_id' => absint( $context['resource_id'] ), |
| 100 |
'calendar_dates_start' => $calendar_dates_start, |
| 101 |
'calendar_dates_end' => wpbc_classic_booking_context_normalize_date( $context['calendar_dates_end'] ), |
| 102 |
'custom_form' => wpbc_classic_booking_context_normalize_form( $context['custom_form'] ), |
| 103 |
'aggregate_resource_ids' => $aggregate_resource_ids, |
| 104 |
'allow_past' => wpbc_classic_booking_context_should_allow_past( $calendar_dates_start ), |
| 105 |
); |
| 106 |
} |
| 107 |
|
| 108 |
/** |
| 109 |
* Base64-url encode a context value without padding. |
| 110 |
* |
| 111 |
* @param string $context_value Value to encode. |
| 112 |
* |
| 113 |
* @return string URL-safe encoded value. |
| 114 |
*/ |
| 115 |
function wpbc_classic_booking_context_base64url_encode( $context_value ) { |
| 116 |
return rtrim( strtr( base64_encode( (string) $context_value ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode |
| 117 |
} |
| 118 |
|
| 119 |
/** |
| 120 |
* Decode one strict base64-url context value. |
| 121 |
* |
| 122 |
* @param string $encoded_value Encoded value. |
| 123 |
* |
| 124 |
* @return string|false Decoded value or false when malformed. |
| 125 |
*/ |
| 126 |
function wpbc_classic_booking_context_base64url_decode( $encoded_value ) { |
| 127 |
$encoded_value = strtr( (string) $encoded_value, '-_', '+/' ); |
| 128 |
$padding = strlen( $encoded_value ) % 4; |
| 129 |
if ( $padding ) { |
| 130 |
$encoded_value .= str_repeat( '=', 4 - $padding ); |
| 131 |
} |
| 132 |
|
| 133 |
return base64_decode( $encoded_value, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode |
| 134 |
} |
| 135 |
|
| 136 |
/** |
| 137 |
* Sign normalized Classic shortcode context for cache-safe AJAX round trips. |
| 138 |
* |
| 139 |
* The HMAC has no time component, so cached front-end pages remain usable until |
| 140 |
* WordPress authentication salts change. No secret or raw signature key is |
| 141 |
* exposed to the browser. |
| 142 |
* |
| 143 |
* @param mixed $context Raw or normalized context. |
| 144 |
* |
| 145 |
* @return string Signed opaque token, or an empty string for incomplete context. |
| 146 |
*/ |
| 147 |
function wpbc_classic_booking_context_encode( $context ) { |
| 148 |
$context = wpbc_classic_booking_context_normalize( $context ); |
| 149 |
if ( |
| 150 |
0 === $context['resource_id'] |
| 151 |
|| '' === $context['calendar_dates_start'] |
| 152 |
|| '' === $context['calendar_dates_end'] |
| 153 |
|| $context['calendar_dates_start'] > $context['calendar_dates_end'] |
| 154 |
) { |
| 155 |
return ''; |
| 156 |
} |
| 157 |
|
| 158 |
$payload = wpbc_classic_booking_context_base64url_encode( wp_json_encode( $context ) ); |
| 159 |
$signature = hash_hmac( 'sha256', $payload, wp_salt( 'auth' ), true ); |
| 160 |
|
| 161 |
return $payload . '.' . wpbc_classic_booking_context_base64url_encode( $signature ); |
| 162 |
} |
| 163 |
|
| 164 |
/** |
| 165 |
* Verify and decode a signed Classic shortcode context token. |
| 166 |
* |
| 167 |
* @param string $context_token Signed token received through AJAX. |
| 168 |
* |
| 169 |
* @return array<string,mixed>|WP_Error Normalized context or a safe validation error. |
| 170 |
*/ |
| 171 |
function wpbc_classic_booking_context_decode( $context_token ) { |
| 172 |
$token_parts = explode( '.', (string) $context_token, 2 ); |
| 173 |
if ( 2 !== count( $token_parts ) ) { |
| 174 |
return new WP_Error( 'classic_booking_context_invalid', __( 'The booking form context could not be verified. Please reload the page and try again.', 'booking' ) ); |
| 175 |
} |
| 176 |
|
| 177 |
$expected_signature = hash_hmac( 'sha256', $token_parts[0], wp_salt( 'auth' ), true ); |
| 178 |
$actual_signature = wpbc_classic_booking_context_base64url_decode( $token_parts[1] ); |
| 179 |
if ( false === $actual_signature || ! hash_equals( $expected_signature, $actual_signature ) ) { |
| 180 |
return new WP_Error( 'classic_booking_context_invalid', __( 'The booking form context could not be verified. Please reload the page and try again.', 'booking' ) ); |
| 181 |
} |
| 182 |
|
| 183 |
$context_json = wpbc_classic_booking_context_base64url_decode( $token_parts[0] ); |
| 184 |
$context = false !== $context_json ? json_decode( $context_json, true ) : null; |
| 185 |
if ( ! is_array( $context ) ) { |
| 186 |
return new WP_Error( 'classic_booking_context_invalid', __( 'The booking form context could not be verified. Please reload the page and try again.', 'booking' ) ); |
| 187 |
} |
| 188 |
|
| 189 |
$context = wpbc_classic_booking_context_normalize( $context ); |
| 190 |
if ( |
| 191 |
0 === $context['resource_id'] |
| 192 |
|| '' === $context['calendar_dates_start'] |
| 193 |
|| '' === $context['calendar_dates_end'] |
| 194 |
|| $context['calendar_dates_start'] > $context['calendar_dates_end'] |
| 195 |
) { |
| 196 |
return new WP_Error( 'classic_booking_context_invalid', __( 'The booking form context could not be verified. Please reload the page and try again.', 'booking' ) ); |
| 197 |
} |
| 198 |
|
| 199 |
return $context; |
| 200 |
} |
| 201 |
|
| 202 |
/** |
| 203 |
* Validate a Classic AJAX request against its signed shortcode boundaries. |
| 204 |
* |
| 205 |
* @param string $context_token Signed Classic context token. |
| 206 |
* @param mixed $resource_id Submitted primary Booking Resource ID. |
| 207 |
* @param array|string $submitted_dates Submitted YYYY-MM-DD dates. |
| 208 |
* @param string $custom_form Submitted Booking Form identifier. |
| 209 |
* @param array|string $aggregate_resource_ids Submitted aggregate Booking Resource IDs. |
| 210 |
* |
| 211 |
* @return array<string,mixed>|WP_Error Verified context or a validation error. |
| 212 |
*/ |
| 213 |
function wpbc_classic_booking_context_validate_submission( $context_token, $resource_id, $submitted_dates, $custom_form = 'standard', $aggregate_resource_ids = array() ) { |
| 214 |
$context = wpbc_classic_booking_context_decode( $context_token ); |
| 215 |
if ( is_wp_error( $context ) ) { |
| 216 |
return $context; |
| 217 |
} |
| 218 |
|
| 219 |
if ( absint( $resource_id ) !== $context['resource_id'] ) { |
| 220 |
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' ) ); |
| 221 |
} |
| 222 |
|
| 223 |
$custom_form = wpbc_classic_booking_context_normalize_form( $custom_form ); |
| 224 |
if ( $custom_form !== $context['custom_form'] ) { |
| 225 |
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' ) ); |
| 226 |
} |
| 227 |
|
| 228 |
if ( is_string( $aggregate_resource_ids ) ) { |
| 229 |
$aggregate_resource_ids = preg_split( '/[;,\s]+/', $aggregate_resource_ids, -1, PREG_SPLIT_NO_EMPTY ); |
| 230 |
} |
| 231 |
$aggregate_resource_ids = array_values( array_unique( array_filter( array_map( 'absint', (array) $aggregate_resource_ids ) ) ) ); |
| 232 |
sort( $aggregate_resource_ids, SORT_NUMERIC ); |
| 233 |
if ( $aggregate_resource_ids !== $context['aggregate_resource_ids'] ) { |
| 234 |
return new WP_Error( 'classic_booking_context_aggregate_mismatch', __( 'The booking resources do not match this calendar. Please reload the page and try again.', 'booking' ) ); |
| 235 |
} |
| 236 |
|
| 237 |
if ( is_string( $submitted_dates ) ) { |
| 238 |
$submitted_dates = preg_split( '/\s*,\s*/', $submitted_dates, -1, PREG_SPLIT_NO_EMPTY ); |
| 239 |
} |
| 240 |
$submitted_dates = array_values( (array) $submitted_dates ); |
| 241 |
if ( empty( $submitted_dates ) ) { |
| 242 |
return new WP_Error( 'classic_booking_context_date_invalid', __( 'The selected booking date is invalid. Please select the date again.', 'booking' ) ); |
| 243 |
} |
| 244 |
|
| 245 |
foreach ( $submitted_dates as $submitted_date ) { |
| 246 |
$submitted_date = wpbc_classic_booking_context_normalize_date( $submitted_date ); |
| 247 |
if ( '' === $submitted_date ) { |
| 248 |
return new WP_Error( 'classic_booking_context_date_invalid', __( 'The selected booking date is invalid. Please select the date again.', 'booking' ) ); |
| 249 |
} |
| 250 |
if ( $submitted_date < $context['calendar_dates_start'] || $submitted_date > $context['calendar_dates_end'] ) { |
| 251 |
return new WP_Error( 'classic_booking_context_date_outside_range', __( 'The selected booking date is outside this calendar range. Please select another date.', 'booking' ) ); |
| 252 |
} |
| 253 |
} |
| 254 |
|
| 255 |
return $context; |
| 256 |
} |
| 257 |
|