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
booking / includes / _front_end / class-fe-booking-context.php

class-fe-booking-context.php in Booking Calendar 11.9, at includes/_front_end/class-fe-booking-context.php

375 lines 16.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 * 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 /**
63 * Normalize one YYYY-MM-DD value and reject impossible calendar dates.
64 *
65 * @param mixed $date_value Candidate date value.
66 *
67 * @return string Valid normalized date or an empty string.
68 */
69 function wpbc_classic_booking_context_normalize_date( $date_value ) {
70 $date_value = sanitize_text_field( (string) $date_value );
71 if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date_value ) ) {
72 return '';
73 }
74
75 $date_object = DateTimeImmutable::createFromFormat( '!Y-m-d', $date_value, wp_timezone() );
76 if ( false === $date_object || $date_object->format( 'Y-m-d' ) !== $date_value ) {
77 return '';
78 }
79
80 return $date_value;
81 }
82
83 /**
84 * Determine whether a Classic shortcode range intentionally starts in the past.
85 *
86 * The historical Booking Calendar contract treats a past calendar_dates_start
87 * value as site-author permission to submit dates from that visible range.
88 *
89 * @param mixed $calendar_dates_start Inclusive shortcode start date.
90 * @param string $today_ymd Optional YYYY-MM-DD comparison date for deterministic callers and tests.
91 *
92 * @return bool True when the valid range start is earlier than today.
93 */
94 function wpbc_classic_booking_context_should_allow_past( $calendar_dates_start, $today_ymd = '' ) {
95 $calendar_dates_start = wpbc_classic_booking_context_normalize_date( $calendar_dates_start );
96 $today_ymd = wpbc_classic_booking_context_normalize_date( $today_ymd );
97
98 if ( '' === $today_ymd ) {
99 $today_ymd = current_time( 'Y-m-d' );
100 }
101
102 return '' !== $calendar_dates_start && $calendar_dates_start < $today_ymd;
103 }
104
105 /**
106 * Normalize the legacy default Booking Form representation.
107 *
108 * The standard form does not render a booking_form_type hidden field, so its
109 * frontend submission uses an empty string even though the shortcode resolver
110 * represents the same form as "standard".
111 *
112 * @param mixed $custom_form Candidate Booking Form slug.
113 *
114 * @return string Sanitized Booking Form slug, using "standard" for an omitted value.
115 */
116 function wpbc_classic_booking_context_normalize_form( $custom_form ) {
117 $custom_form = sanitize_text_field( (string) $custom_form );
118
119 return '' === $custom_form ? 'standard' : $custom_form;
120 }
121
122 /**
123 * Normalize additional aggregate Booking Resource IDs for signed contexts.
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 *
167 * @param mixed $context Raw context values.
168 *
169 * @return array<string,mixed> Stable context contract.
170 */
171 function wpbc_classic_booking_context_normalize( $context ) {
172 $context = is_array( $context ) ? $context : array();
173 $context = wp_parse_args(
174 $context,
175 array(
176 'context_version' => 0,
177 'booking_workflow' => 'classic',
178 'resource_id' => 0,
179 'calendar_dates_start' => '',
180 'calendar_dates_end' => '',
181 'custom_form' => 'standard',
182 'aggregate_resource_ids' => array(),
183 'allow_past' => false,
184 )
185 );
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 }
193
194 // Derive permission from the site-authored date boundary; never trust a caller-supplied allow_past flag.
195 return array(
196 'context_version' => absint( $context['context_version'] ),
197 'booking_workflow' => $booking_workflow,
198 'resource_id' => $resource_id,
199 'calendar_dates_start' => $calendar_dates_start,
200 'calendar_dates_end' => wpbc_classic_booking_context_normalize_date( $context['calendar_dates_end'] ),
201 'custom_form' => wpbc_classic_booking_context_normalize_form( $context['custom_form'] ),
202 'aggregate_resource_ids' => $aggregate_resource_ids,
203 'allow_past' => wpbc_classic_booking_context_should_allow_past( $calendar_dates_start ),
204 );
205 }
206
207 /**
208 * Base64-url encode a context value without padding.
209 *
210 * @param string $context_value Value to encode.
211 *
212 * @return string URL-safe encoded value.
213 */
214 function wpbc_classic_booking_context_base64url_encode( $context_value ) {
215 return rtrim( strtr( base64_encode( (string) $context_value ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
216 }
217
218 /**
219 * Decode one strict base64-url context value.
220 *
221 * @param string $encoded_value Encoded value.
222 *
223 * @return string|false Decoded value or false when malformed.
224 */
225 function wpbc_classic_booking_context_base64url_decode( $encoded_value ) {
226 $encoded_value = strtr( (string) $encoded_value, '-_', '+/' );
227 $padding = strlen( $encoded_value ) % 4;
228 if ( $padding ) {
229 $encoded_value .= str_repeat( '=', 4 - $padding );
230 }
231
232 return base64_decode( $encoded_value, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
233 }
234
235 /**
236 * Sign a normalized native Booking Form context for cache-safe AJAX round trips.
237 *
238 * The HMAC has no time component, so cached front-end pages remain usable until
239 * WordPress authentication salts change. No secret or raw signature key is
240 * exposed to the browser.
241 *
242 * @param mixed $context Raw or normalized context.
243 *
244 * @return string Signed opaque token, or an empty string for invalid context.
245 */
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();
249 $context = wpbc_classic_booking_context_normalize( $context );
250 if (
251 0 === $context['resource_id']
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 )
257 ) {
258 return '';
259 }
260
261 $payload = wpbc_classic_booking_context_base64url_encode( wp_json_encode( $context ) );
262 $signature = hash_hmac( 'sha256', $payload, wp_salt( 'auth' ), true );
263
264 return $payload . '.' . wpbc_classic_booking_context_base64url_encode( $signature );
265 }
266
267 /**
268 * Verify and decode a signed native Booking Form context token.
269 *
270 * @param string $context_token Signed token received through AJAX.
271 *
272 * @return array<string,mixed>|WP_Error Normalized context or a safe validation error.
273 */
274 function wpbc_classic_booking_context_decode( $context_token ) {
275 $token_parts = explode( '.', (string) $context_token, 2 );
276 if ( 2 !== count( $token_parts ) ) {
277 return new WP_Error( 'classic_booking_context_invalid', __( 'The booking form context could not be verified. Please reload the page and try again.', 'booking' ) );
278 }
279
280 $expected_signature = hash_hmac( 'sha256', $token_parts[0], wp_salt( 'auth' ), true );
281 $actual_signature = wpbc_classic_booking_context_base64url_decode( $token_parts[1] );
282 if ( false === $actual_signature || ! hash_equals( $expected_signature, $actual_signature ) ) {
283 return new WP_Error( 'classic_booking_context_invalid', __( 'The booking form context could not be verified. Please reload the page and try again.', 'booking' ) );
284 }
285
286 $context_json = wpbc_classic_booking_context_base64url_decode( $token_parts[0] );
287 $context = false !== $context_json ? json_decode( $context_json, true ) : null;
288 if ( ! is_array( $context ) ) {
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' ) );
290 }
291
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 }
299 if (
300 0 === $context['resource_id']
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 )
306 ) {
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' ) );
308 }
309
310 return $context;
311 }
312
313 /**
314 * Validate a Booking Form AJAX request against its signed server-rendered boundaries.
315 *
316 * @param string $context_token Signed Booking Form context token.
317 * @param mixed $resource_id Submitted primary Booking Resource ID.
318 * @param array|string $submitted_dates Submitted YYYY-MM-DD dates.
319 * @param string $custom_form Submitted Booking Form identifier.
320 * @param array|string $aggregate_resource_ids Submitted aggregate Booking Resource IDs.
321 *
322 * @return array<string,mixed>|WP_Error Verified context or a validation error.
323 */
324 function wpbc_classic_booking_context_validate_submission( $context_token, $resource_id, $submitted_dates, $custom_form = 'standard', $aggregate_resource_ids = array() ) {
325 $context = wpbc_classic_booking_context_decode( $context_token );
326 if ( is_wp_error( $context ) ) {
327 return $context;
328 }
329
330 if ( absint( $resource_id ) !== $context['resource_id'] ) {
331 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' ) );
332 }
333
334 $custom_form = wpbc_classic_booking_context_normalize_form( $custom_form );
335 if ( $custom_form !== $context['custom_form'] ) {
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' ) );
337 }
338
339 $aggregate_resource_ids = wpbc_classic_booking_context_normalize_aggregate_resource_ids( $aggregate_resource_ids, $context['resource_id'] );
340 if ( $aggregate_resource_ids !== $context['aggregate_resource_ids'] ) {
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 );
350 }
351
352 if ( is_string( $submitted_dates ) ) {
353 $submitted_dates = preg_split( '/\s*,\s*/', $submitted_dates, -1, PREG_SPLIT_NO_EMPTY );
354 }
355 $submitted_dates = array_values( (array) $submitted_dates );
356 if ( empty( $submitted_dates ) ) {
357 return new WP_Error( 'classic_booking_context_date_invalid', __( 'The selected booking date is invalid. Please select the date again.', 'booking' ) );
358 }
359
360 foreach ( $submitted_dates as $submitted_date ) {
361 $submitted_date = wpbc_classic_booking_context_normalize_date( $submitted_date );
362 if ( '' === $submitted_date ) {
363 return new WP_Error( 'classic_booking_context_date_invalid', __( 'The selected booking date is invalid. Please select the date again.', 'booking' ) );
364 }
365 if (
366 '' !== $context['calendar_dates_start']
367 && ( $submitted_date < $context['calendar_dates_start'] || $submitted_date > $context['calendar_dates_end'] )
368 ) {
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' ) );
370 }
371 }
372
373 return $context;
374 }
375