| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | - * Signed context for Classic Booking Calendar shortcode AJAX requests. | |
| 3 | + * Signed context for native Booking Form AJAX requests. | |
| 4 | 4 | * |
| 5 | 5 | * @package Booking Calendar |
| 6 | 6 | */ |
| 7 | 7 | |
| @@ -9,8 +9,21 @@ | ||
| 9 | 9 | exit; |
| 10 | 10 | } |
| 11 | 11 | |
| 12 | 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 | +/** | |
| 13 | 26 | * Normalize one YYYY-MM-DD value and reject impossible calendar dates. |
| 14 | 27 | * |
| 15 | 28 | * @param mixed $date_value Candidate date value. |
| 16 | 29 | * |
| @@ -69,10 +82,52 @@ | ||
| 69 | 82 | return '' === $custom_form ? 'standard' : $custom_form; |
| 70 | 83 | } |
| 71 | 84 | |
| 72 | 85 | /** |
| 73 | - * Normalize Classic shortcode context before it is signed or consumed. | |
| 86 | + * Normalize additional aggregate Booking Resource IDs for signed contexts. | |
| 74 | 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 | + * | |
| 75 | 130 | * @param mixed $context Raw context values. |
| 76 | 131 | * |
| 77 | 132 | * @return array<string,mixed> Stable context contract. |
| 78 | 133 | */ |
| @@ -80,8 +135,10 @@ | ||
| 80 | 135 | $context = is_array( $context ) ? $context : array(); |
| 81 | 136 | $context = wp_parse_args( |
| 82 | 137 | $context, |
| 83 | 138 | array( |
| 139 | + 'context_version' => 0, | |
| 140 | + 'booking_workflow' => 'classic', | |
| 84 | 141 | 'resource_id' => 0, |
| 85 | 142 | 'calendar_dates_start' => '', |
| 86 | 143 | 'calendar_dates_end' => '', |
| 87 | 144 | 'custom_form' => 'standard', |
| @@ -88,16 +145,21 @@ | ||
| 88 | 145 | 'aggregate_resource_ids' => array(), |
| 89 | 146 | 'allow_past' => false, |
| 90 | 147 | ) |
| 91 | 148 | ); |
| 92 | - $calendar_dates_start = wpbc_classic_booking_context_normalize_date( $context['calendar_dates_start'] ); | |
| 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 | + } | |
| 93 | 156 | |
| 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 | 157 | // Derive permission from the site-authored date boundary; never trust a caller-supplied allow_past flag. |
| 98 | 158 | return array( |
| 99 | - 'resource_id' => absint( $context['resource_id'] ), | |
| 159 | + 'context_version' => absint( $context['context_version'] ), | |
| 160 | + 'booking_workflow' => $booking_workflow, | |
| 161 | + 'resource_id' => $resource_id, | |
| 100 | 162 | 'calendar_dates_start' => $calendar_dates_start, |
| 101 | 163 | 'calendar_dates_end' => wpbc_classic_booking_context_normalize_date( $context['calendar_dates_end'] ), |
| 102 | 164 | 'custom_form' => wpbc_classic_booking_context_normalize_form( $context['custom_form'] ), |
| 103 | 165 | 'aggregate_resource_ids' => $aggregate_resource_ids, |
| @@ -133,9 +195,9 @@ | ||
| 133 | 195 | return base64_decode( $encoded_value, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode |
| 134 | 196 | } |
| 135 | 197 | |
| 136 | 198 | /** |
| 137 | - * Sign normalized Classic shortcode context for cache-safe AJAX round trips. | |
| 199 | + * Sign a normalized native Booking Form context for cache-safe AJAX round trips. | |
| 138 | 200 | * |
| 139 | 201 | * The HMAC has no time component, so cached front-end pages remain usable until |
| 140 | 202 | * WordPress authentication salts change. No secret or raw signature key is |
| 141 | 203 | * exposed to the browser. |
| @@ -141,17 +203,21 @@ | ||
| 141 | 203 | * exposed to the browser. |
| 142 | 204 | * |
| 143 | 205 | * @param mixed $context Raw or normalized context. |
| 144 | 206 | * |
| 145 | - * @return string Signed opaque token, or an empty string for incomplete context. | |
| 207 | + * @return string Signed opaque token, or an empty string for invalid context. | |
| 146 | 208 | */ |
| 147 | 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(); | |
| 148 | 212 | $context = wpbc_classic_booking_context_normalize( $context ); |
| 149 | 213 | if ( |
| 150 | 214 | 0 === $context['resource_id'] |
| 151 | - || '' === $context['calendar_dates_start'] | |
| 152 | - || '' === $context['calendar_dates_end'] | |
| 153 | - || $context['calendar_dates_start'] > $context['calendar_dates_end'] | |
| 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 | + ) | |
| 154 | 220 | ) { |
| 155 | 221 | return ''; |
| 156 | 222 | } |
| 157 | 223 | |
| @@ -161,9 +227,9 @@ | ||
| 161 | 227 | return $payload . '.' . wpbc_classic_booking_context_base64url_encode( $signature ); |
| 162 | 228 | } |
| 163 | 229 | |
| 164 | 230 | /** |
| 165 | - * Verify and decode a signed Classic shortcode context token. | |
| 231 | + * Verify and decode a signed native Booking Form context token. | |
| 166 | 232 | * |
| 167 | 233 | * @param string $context_token Signed token received through AJAX. |
| 168 | 234 | * |
| 169 | 235 | * @return array<string,mixed>|WP_Error Normalized context or a safe validation error. |
| @@ -186,13 +252,18 @@ | ||
| 186 | 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' ) ); |
| 187 | 253 | } |
| 188 | 254 | |
| 189 | 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 | + } | |
| 190 | 259 | if ( |
| 191 | 260 | 0 === $context['resource_id'] |
| 192 | - || '' === $context['calendar_dates_start'] | |
| 193 | - || '' === $context['calendar_dates_end'] | |
| 194 | - || $context['calendar_dates_start'] > $context['calendar_dates_end'] | |
| 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 | + ) | |
| 195 | 266 | ) { |
| 196 | 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' ) ); |
| 197 | 268 | } |
| 198 | 269 | |
| @@ -199,11 +270,11 @@ | ||
| 199 | 270 | return $context; |
| 200 | 271 | } |
| 201 | 272 | |
| 202 | 273 | /** |
| 203 | - * Validate a Classic AJAX request against its signed shortcode boundaries. | |
| 274 | + * Validate a Booking Form AJAX request against its signed server-rendered boundaries. | |
| 204 | 275 | * |
| 205 | - * @param string $context_token Signed Classic context token. | |
| 276 | + * @param string $context_token Signed Booking Form context token. | |
| 206 | 277 | * @param mixed $resource_id Submitted primary Booking Resource ID. |
| 207 | 278 | * @param array|string $submitted_dates Submitted YYYY-MM-DD dates. |
| 208 | 279 | * @param string $custom_form Submitted Booking Form identifier. |
| 209 | 280 | * @param array|string $aggregate_resource_ids Submitted aggregate Booking Resource IDs. |
| @@ -224,15 +295,19 @@ | ||
| 224 | 295 | if ( $custom_form !== $context['custom_form'] ) { |
| 225 | 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' ) ); |
| 226 | 297 | } |
| 227 | 298 | |
| 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 ); | |
| 299 | + $aggregate_resource_ids = wpbc_classic_booking_context_normalize_aggregate_resource_ids( $aggregate_resource_ids, $context['resource_id'] ); | |
| 233 | 300 | 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' ) ); | |
| 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 ); | |
| 235 | 310 | } |
| 236 | 311 | |
| 237 | 312 | if ( is_string( $submitted_dates ) ) { |
| 238 | 313 | $submitted_dates = preg_split( '/\s*,\s*/', $submitted_dates, -1, PREG_SPLIT_NO_EMPTY ); |
| @@ -246,9 +321,12 @@ | ||
| 246 | 321 | $submitted_date = wpbc_classic_booking_context_normalize_date( $submitted_date ); |
| 247 | 322 | if ( '' === $submitted_date ) { |
| 248 | 323 | return new WP_Error( 'classic_booking_context_date_invalid', __( 'The selected booking date is invalid. Please select the date again.', 'booking' ) ); |
| 249 | 324 | } |
| 250 | - if ( $submitted_date < $context['calendar_dates_start'] || $submitted_date > $context['calendar_dates_end'] ) { | |
| 325 | + if ( | |
| 326 | + '' !== $context['calendar_dates_start'] | |
| 327 | + && ( $submitted_date < $context['calendar_dates_start'] || $submitted_date > $context['calendar_dates_end'] ) | |
| 328 | + ) { | |
| 251 | 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' ) ); |
| 252 | 330 | } |
| 253 | 331 | } |
| 254 | 332 | |