PluginProbe
Booking Calendar / 11.8.3
Booking Calendar v11.8.3
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 10.11 10.11.2 All 203 releases
booking / includes / _front_end / class-fe-booking-context.php

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

335 lines 13.9 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 * 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