PluginProbe
Booking Calendar / 11.9
Booking Calendar v11.9
11.9 11.8.4 11.8.3 11.8.2 11.8.1 11.8 11.7 11.6.1 11.6 11.5 11.4.3 11.4.2 11.4.1 11.4 11.3 11.2.1 11.2 11.1 11.0 10.15.7 10.15.6 10.1.3 10.10 10.10.1 10.10.2 All 205 releases
← All changes | includes/_front_end/class-fe-booking-context.php +143 -25 11.6 → 11.9 View file →
@@ -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,58 @@
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 +/**
26 + * Resolve an actionable visitor message for a Booking Form context failure.
27 + *
28 + * The frontend message registry makes these notices translatable and editable
29 + * with the other Form Messages. The local defaults keep this security boundary
30 + * usable in isolated tests and integrations that load the context API before
31 + * the central message registry.
32 + *
33 + * @param string $message_key Stable frontend message key.
34 + * @param mixed $resource_id Booking Resource ID associated with the form.
35 + *
36 + * @return string Plain-text visitor message, or an empty string for an unsupported key.
37 + */
38 +function wpbc_classic_booking_context_get_visitor_message( $message_key, $resource_id = 0 ) {
39 + $resource_id = absint( $resource_id );
40 + $fallbacks = array(
41 + /* translators: Keep the {resource_id} placeholder unchanged. */
42 + 'message_booking_form_context_required' => __( 'This booking form is not connected to a valid calendar for Booking Resource ID {resource_id}. This can happen when the same Booking Resource is used more than once on the page, including in hidden content, or when the page cache is outdated. Reload the page and try again. If the problem continues, ask the site administrator to remove duplicate forms for this Booking Resource and clear the page cache.', 'booking' ),
43 + /* translators: Keep the {resource_id} placeholder unchanged. */
44 + 'message_booking_form_context_expired' => __( 'This booking form was generated by an older or cached version of the page for Booking Resource ID {resource_id}. Reload the page and try again. If the problem continues, ask the site administrator to clear the page cache and remove any duplicate calendar or form for this Booking Resource.', 'booking' ),
45 + );
46 +
47 + if ( ! isset( $fallbacks[ $message_key ] ) ) {
48 + return '';
49 + }
50 +
51 + $replacements = array( '{resource_id}' => (string) $resource_id );
52 + if ( function_exists( 'wpbc_frontend_messages__get' ) ) {
53 + $resolved_message = wpbc_frontend_messages__get( $message_key, $replacements, $resource_id );
54 + if ( '' !== $resolved_message ) {
55 + return $resolved_message;
56 + }
57 + }
58 +
59 + return strtr( $fallbacks[ $message_key ], $replacements );
60 +}
61 +
62 +/**
13 63 * Normalize one YYYY-MM-DD value and reject impossible calendar dates.
14 64 *
15 65 * @param mixed $date_value Candidate date value.
16 66 *
@@ -69,10 +119,52 @@
69 119 return '' === $custom_form ? 'standard' : $custom_form;
70 120 }
71 121
72 122 /**
73 - * Normalize Classic shortcode context before it is signed or consumed.
123 + * Normalize additional aggregate Booking Resource IDs for signed contexts.
74 124 *
125 + * The legacy shortcode renderer represents an aggregate form as the primary
126 + * Resource followed by its additional Resources. Calendar runtime state
127 + * intentionally stores only the additional Resources because the primary is
128 + * already carried separately as resource_id. Removing that separately bound
129 + * primary gives both established shapes one canonical representation while
130 + * preserving an exact-set security comparison for every additional Resource.
131 + *
132 + * @param array|string|int $aggregate_resource_ids Candidate Resource IDs.
133 + * @param mixed $primary_resource_id Separately bound primary Resource ID.
134 + *
135 + * @return int[] Sorted unique positive IDs excluding the primary Resource.
136 + */
137 +function wpbc_classic_booking_context_normalize_aggregate_resource_ids( $aggregate_resource_ids, $primary_resource_id = 0 ) {
138 + $aggregate_resource_ids = is_array( $aggregate_resource_ids ) ? $aggregate_resource_ids : array( $aggregate_resource_ids );
139 + $primary_resource_id = absint( $primary_resource_id );
140 + $normalized_resource_ids = array();
141 +
142 + foreach ( $aggregate_resource_ids as $aggregate_resource_id ) {
143 + if ( ! is_int( $aggregate_resource_id ) && ! is_float( $aggregate_resource_id ) && ! is_string( $aggregate_resource_id ) ) {
144 + continue;
145 + }
146 +
147 + $resource_id_parts = preg_split( '/[;,\s]+/', (string) $aggregate_resource_id, -1, PREG_SPLIT_NO_EMPTY );
148 + foreach ( (array) $resource_id_parts as $resource_id_part ) {
149 + $resource_id = absint( $resource_id_part );
150 + if ( ! $resource_id || $resource_id === $primary_resource_id ) {
151 + continue;
152 + }
153 +
154 + $normalized_resource_ids[ $resource_id ] = $resource_id;
155 + }
156 + }
157 +
158 + $normalized_resource_ids = array_values( $normalized_resource_ids );
159 + sort( $normalized_resource_ids, SORT_NUMERIC );
160 +
161 + return $normalized_resource_ids;
162 +}
163 +
164 +/**
165 + * Normalize the native Booking Form context before it is signed or consumed.
166 + *
75 167 * @param mixed $context Raw context values.
76 168 *
77 169 * @return array<string,mixed> Stable context contract.
78 170 */
@@ -80,8 +172,10 @@
80 172 $context = is_array( $context ) ? $context : array();
81 173 $context = wp_parse_args(
82 174 $context,
83 175 array(
176 + 'context_version' => 0,
177 + 'booking_workflow' => 'classic',
84 178 'resource_id' => 0,
85 179 'calendar_dates_start' => '',
86 180 'calendar_dates_end' => '',
87 181 'custom_form' => 'standard',
@@ -88,16 +182,21 @@
88 182 'aggregate_resource_ids' => array(),
89 183 'allow_past' => false,
90 184 )
91 185 );
92 - $calendar_dates_start = wpbc_classic_booking_context_normalize_date( $context['calendar_dates_start'] );
186 + $calendar_dates_start = wpbc_classic_booking_context_normalize_date( $context['calendar_dates_start'] );
187 + $resource_id = absint( $context['resource_id'] );
188 + $aggregate_resource_ids = wpbc_classic_booking_context_normalize_aggregate_resource_ids( $context['aggregate_resource_ids'], $resource_id );
189 + $booking_workflow = sanitize_key( (string) $context['booking_workflow'] );
190 + if ( ! in_array( $booking_workflow, array( 'classic', 'appointment', 'resource_selector' ), true ) ) {
191 + $booking_workflow = 'classic';
192 + }
93 193
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 194 // Derive permission from the site-authored date boundary; never trust a caller-supplied allow_past flag.
98 195 return array(
99 - 'resource_id' => absint( $context['resource_id'] ),
196 + 'context_version' => absint( $context['context_version'] ),
197 + 'booking_workflow' => $booking_workflow,
198 + 'resource_id' => $resource_id,
100 199 'calendar_dates_start' => $calendar_dates_start,
101 200 'calendar_dates_end' => wpbc_classic_booking_context_normalize_date( $context['calendar_dates_end'] ),
102 201 'custom_form' => wpbc_classic_booking_context_normalize_form( $context['custom_form'] ),
103 202 'aggregate_resource_ids' => $aggregate_resource_ids,
@@ -133,9 +232,9 @@
133 232 return base64_decode( $encoded_value, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
134 233 }
135 234
136 235 /**
137 - * Sign normalized Classic shortcode context for cache-safe AJAX round trips.
236 + * Sign a normalized native Booking Form context for cache-safe AJAX round trips.
138 237 *
139 238 * The HMAC has no time component, so cached front-end pages remain usable until
140 239 * WordPress authentication salts change. No secret or raw signature key is
141 240 * exposed to the browser.
@@ -141,17 +240,21 @@
141 240 * exposed to the browser.
142 241 *
143 242 * @param mixed $context Raw or normalized context.
144 243 *
145 - * @return string Signed opaque token, or an empty string for incomplete context.
244 + * @return string Signed opaque token, or an empty string for invalid context.
146 245 */
147 246 function wpbc_classic_booking_context_encode( $context ) {
247 + $context = is_array( $context ) ? $context : array();
248 + $context['context_version'] = wpbc_classic_booking_context_get_version();
148 249 $context = wpbc_classic_booking_context_normalize( $context );
149 250 if (
150 251 0 === $context['resource_id']
151 - || '' === $context['calendar_dates_start']
152 - || '' === $context['calendar_dates_end']
153 - || $context['calendar_dates_start'] > $context['calendar_dates_end']
252 + || ( ( '' === $context['calendar_dates_start'] ) !== ( '' === $context['calendar_dates_end'] ) )
253 + || (
254 + '' !== $context['calendar_dates_start']
255 + && $context['calendar_dates_start'] > $context['calendar_dates_end']
256 + )
154 257 ) {
155 258 return '';
156 259 }
157 260
@@ -161,9 +264,9 @@
161 264 return $payload . '.' . wpbc_classic_booking_context_base64url_encode( $signature );
162 265 }
163 266
164 267 /**
165 - * Verify and decode a signed Classic shortcode context token.
268 + * Verify and decode a signed native Booking Form context token.
166 269 *
167 270 * @param string $context_token Signed token received through AJAX.
168 271 *
169 272 * @return array<string,mixed>|WP_Error Normalized context or a safe validation error.
@@ -186,13 +289,21 @@
186 289 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 290 }
188 291
189 292 $context = wpbc_classic_booking_context_normalize( $context );
293 + if ( wpbc_classic_booking_context_get_version() !== $context['context_version'] ) {
294 + return new WP_Error(
295 + 'classic_booking_context_expired',
296 + wpbc_classic_booking_context_get_visitor_message( 'message_booking_form_context_expired', $context['resource_id'] )
297 + );
298 + }
190 299 if (
191 300 0 === $context['resource_id']
192 - || '' === $context['calendar_dates_start']
193 - || '' === $context['calendar_dates_end']
194 - || $context['calendar_dates_start'] > $context['calendar_dates_end']
301 + || ( ( '' === $context['calendar_dates_start'] ) !== ( '' === $context['calendar_dates_end'] ) )
302 + || (
303 + '' !== $context['calendar_dates_start']
304 + && $context['calendar_dates_start'] > $context['calendar_dates_end']
305 + )
195 306 ) {
196 307 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 308 }
198 309
@@ -199,11 +310,11 @@
199 310 return $context;
200 311 }
201 312
202 313 /**
203 - * Validate a Classic AJAX request against its signed shortcode boundaries.
314 + * Validate a Booking Form AJAX request against its signed server-rendered boundaries.
204 315 *
205 - * @param string $context_token Signed Classic context token.
316 + * @param string $context_token Signed Booking Form context token.
206 317 * @param mixed $resource_id Submitted primary Booking Resource ID.
207 318 * @param array|string $submitted_dates Submitted YYYY-MM-DD dates.
208 319 * @param string $custom_form Submitted Booking Form identifier.
209 320 * @param array|string $aggregate_resource_ids Submitted aggregate Booking Resource IDs.
@@ -224,15 +335,19 @@
224 335 if ( $custom_form !== $context['custom_form'] ) {
225 336 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 337 }
227 338
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 );
339 + $aggregate_resource_ids = wpbc_classic_booking_context_normalize_aggregate_resource_ids( $aggregate_resource_ids, $context['resource_id'] );
233 340 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' ) );
341 + $troubleshooting_url = 'https://wpbookingcalendar.com/faq/troubleshooting-the-booking-resources-do-not-match-this-calendar/';
342 + $aggregate_mismatch_message = esc_html__( 'The booking resources do not match this calendar. Please reload the page and try again.', 'booking' );
343 + $aggregate_mismatch_message .= sprintf(
344 + '<br><a href="%1$s" target="_blank" rel="noopener noreferrer">%2$s</a>',
345 + esc_url( $troubleshooting_url ),
346 + esc_html__( 'Open the troubleshooting guide.', 'booking' )
347 + );
348 +
349 + return new WP_Error( 'classic_booking_context_aggregate_mismatch', $aggregate_mismatch_message );
235 350 }
236 351
237 352 if ( is_string( $submitted_dates ) ) {
238 353 $submitted_dates = preg_split( '/\s*,\s*/', $submitted_dates, -1, PREG_SPLIT_NO_EMPTY );
@@ -246,9 +361,12 @@
246 361 $submitted_date = wpbc_classic_booking_context_normalize_date( $submitted_date );
247 362 if ( '' === $submitted_date ) {
248 363 return new WP_Error( 'classic_booking_context_date_invalid', __( 'The selected booking date is invalid. Please select the date again.', 'booking' ) );
249 364 }
250 - if ( $submitted_date < $context['calendar_dates_start'] || $submitted_date > $context['calendar_dates_end'] ) {
365 + if (
366 + '' !== $context['calendar_dates_start']
367 + && ( $submitted_date < $context['calendar_dates_start'] || $submitted_date > $context['calendar_dates_end'] )
368 + ) {
251 369 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 370 }
253 371 }
254 372