PluginProbe
Booking Calendar / 11.8.4
Booking Calendar v11.8.4
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 10.11 All 204 releases
booking / includes / booking-appointment / booking-appointment__config.php

booking-appointment__config.php in Booking Calendar 11.8.4, at includes/booking-appointment/booking-appointment__config.php

378 lines 15.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Appointment shortcode configuration normalization and signing.
4 *
5 * @package Booking Calendar
6 */
7
8 if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10 }
11
12 /**
13 * Convert a comma-separated value or array to unique positive IDs.
14 *
15 * @param mixed $value Raw ID collection.
16 *
17 * @return int[] Normalized IDs.
18 */
19 function wpbc_booking_appointment_normalize_ids( $value ) {
20 if ( is_string( $value ) ) {
21 $value = preg_split( '/[;,\s]+/', $value, -1, PREG_SPLIT_NO_EMPTY );
22 }
23
24 return array_values( array_unique( array_filter( array_map( 'absint', (array) $value ) ) ) );
25 }
26
27 /**
28 * Convert a shortcode-style value to a strict Boolean.
29 *
30 * @param mixed $raw_value Raw Boolean-like value.
31 * @param bool $default_value Value used when the raw value is null.
32 *
33 * @return bool Normalized Boolean.
34 */
35 function wpbc_booking_appointment_normalize_boolean( $raw_value, $default_value = false ) {
36 if ( null === $raw_value ) {
37 return (bool) $default_value;
38 }
39
40 if ( is_string( $raw_value ) ) {
41 $raw_value = strtolower( trim( $raw_value ) );
42 }
43
44 return ! in_array( $raw_value, array( false, 0, '0', 'false', 'off', 'no', '' ), true );
45 }
46
47 /**
48 * Normalize a safe public Appointment catalog item width.
49 *
50 * Bare numbers are treated as pixels. Only simple dimensions are accepted so
51 * a shortcode value cannot introduce an arbitrary inline CSS declaration.
52 *
53 * @param mixed $raw_width Raw shortcode width.
54 *
55 * @return string Normalized CSS width or an empty string for automatic width.
56 */
57 function wpbc_booking_appointment_normalize_css_width( $raw_width ) {
58 if ( is_int( $raw_width ) || is_float( $raw_width ) ) {
59 $raw_width = (string) $raw_width . 'px';
60 }
61
62 $raw_width = strtolower( trim( (string) $raw_width ) );
63 if ( '' === $raw_width || 'auto' === $raw_width ) {
64 return '';
65 }
66 if ( preg_match( '/^\d+(?:\.\d+)?$/', $raw_width ) ) {
67 $raw_width .= 'px';
68 }
69 if ( ! preg_match( '/^(\d+(?:\.\d+)?)(px|%|rem|em|vw)$/', $raw_width, $matches ) ) {
70 return '';
71 }
72
73 $numeric_width = (float) $matches[1];
74 $width_unit = $matches[2];
75 $maximum_width = in_array( $width_unit, array( '%', 'vw' ), true ) ? 100 : ( 'px' === $width_unit ? 2000 : 100 );
76 if ( $numeric_width <= 0 || $numeric_width > $maximum_width ) {
77 return '';
78 }
79
80 $normalized_width = rtrim( rtrim( number_format( $numeric_width, 4, '.', '' ), '0' ), '.' );
81
82 return $normalized_width . $width_unit;
83 }
84
85 /**
86 * Normalize shortcode attributes into the stable AJAX configuration contract.
87 *
88 * @param mixed $attributes Raw shortcode attributes or decoded configuration.
89 *
90 * @return array<string,mixed> Safe Appointment configuration.
91 */
92 function wpbc_booking_appointment_normalize_config( $attributes ) {
93 $attributes = is_array( $attributes ) ? $attributes : array();
94 $defaults = array(
95 'service_id' => 0,
96 'provider_id' => 0,
97 'service_ids' => array(),
98 'provider_ids' => array(),
99 'cal_count' => 1,
100 'start_month_calendar' => false,
101 'calendar_dates_start' => '',
102 'calendar_dates_end' => '',
103 'options' => '',
104 'form_type' => '',
105 'auto_select_provider' => false,
106 'catalog_layout' => 'grid',
107 'show_resource_filters' => false,
108 'show_resource_image' => true,
109 'show_resource_title' => true,
110 'show_resource_description' => true,
111 'catalog_item_width' => '',
112 'catalog_item_max_width' => 0,
113 'catalog_grid_items_per_row' => 0,
114 'catalog_list_items_per_row' => 0,
115 'show_resource_hierarchy' => true,
116 'show_availability' => true,
117 'show_starting_price' => true,
118 'show_progress' => true,
119 'progress_item_1_title' => null,
120 'progress_item_1_number' => null,
121 'progress_item_2_title' => null,
122 'progress_item_2_number' => null,
123 'progress_item_3_title' => null,
124 'progress_item_3_number' => null,
125 'screen_1_title' => null,
126 'screen_1_description' => null,
127 'screen_2_title' => null,
128 'screen_2_description' => null,
129 'allow_past' => false,
130 'return_url' => '',
131 );
132
133 // Decode the public shortcode aliases only before values enter the signed token.
134 if ( isset( $attributes['services'] ) && ! isset( $attributes['service_ids'] ) ) {
135 $attributes['service_ids'] = $attributes['services'];
136 }
137 if ( isset( $attributes['providers'] ) && ! isset( $attributes['provider_ids'] ) ) {
138 $attributes['provider_ids'] = $attributes['providers'];
139 }
140 if ( isset( $attributes['nummonths'] ) && ! isset( $attributes['cal_count'] ) ) {
141 $attributes['cal_count'] = $attributes['nummonths'];
142 }
143 if ( isset( $attributes['startmonth'] ) && ! isset( $attributes['start_month_calendar'] ) ) {
144 $attributes['start_month_calendar'] = $attributes['startmonth'];
145 }
146
147 // Normalize earlier descriptive names before signing one indexed contract.
148 $progress_attribute_aliases = array(
149 'progress_service_title' => 'progress_item_1_title',
150 'progress_service_number' => 'progress_item_1_number',
151 'progress_provider_title' => 'progress_item_2_title',
152 'progress_provider_number' => 'progress_item_2_number',
153 'progress_details_title' => 'progress_item_3_title',
154 'progress_details_number' => 'progress_item_3_number',
155 );
156 foreach ( $progress_attribute_aliases as $legacy_attribute => $normalized_attribute ) {
157 if ( array_key_exists( $legacy_attribute, $attributes ) && ! array_key_exists( $normalized_attribute, $attributes ) ) {
158 $attributes[ $normalized_attribute ] = $attributes[ $legacy_attribute ];
159 }
160 unset( $attributes[ $legacy_attribute ] );
161 }
162
163 $config = wp_parse_args( $attributes, $defaults );
164
165 $config['service_id'] = absint( $config['service_id'] );
166 $config['provider_id'] = absint( $config['provider_id'] );
167 $config['service_ids'] = wpbc_booking_appointment_normalize_ids( $config['service_ids'] );
168 $config['provider_ids'] = wpbc_booking_appointment_normalize_ids( $config['provider_ids'] );
169 $config['cal_count'] = min( 24, max( 1, absint( $config['cal_count'] ) ) );
170
171 if ( $config['service_id'] && ! in_array( $config['service_id'], $config['service_ids'], true ) ) {
172 $config['service_ids'][] = $config['service_id'];
173 }
174 if ( $config['provider_id'] && ! in_array( $config['provider_id'], $config['provider_ids'], true ) ) {
175 $config['provider_ids'][] = $config['provider_id'];
176 }
177
178 $start_month = $config['start_month_calendar'];
179 if ( is_array( $start_month ) ) {
180 $year = isset( $start_month[0] ) ? absint( $start_month[0] ) : 0;
181 $month = isset( $start_month[1] ) ? absint( $start_month[1] ) : 0;
182 $start_month = ( $year && $month >= 1 && $month <= 12 ) ? array( $year, $month ) : false;
183 } elseif ( is_string( $start_month ) && preg_match( '/^(\d{4})[-\/]?(\d{1,2})$/', $start_month, $matches ) ) {
184 $month = absint( $matches[2] );
185 $start_month = ( $month >= 1 && $month <= 12 ) ? array( absint( $matches[1] ), $month ) : false;
186 } else {
187 $start_month = false;
188 }
189 $config['start_month_calendar'] = $start_month;
190
191 foreach ( array( 'calendar_dates_start', 'calendar_dates_end' ) as $date_key ) {
192 $date_value = sanitize_text_field( (string) $config[ $date_key ] );
193 $config[ $date_key ] = preg_match( '/^\d{4}-\d{2}-\d{2}$/', $date_value ) ? $date_value : '';
194 }
195
196 $config['options'] = sanitize_text_field( (string) $config['options'] );
197 $config['form_type'] = sanitize_text_field( (string) $config['form_type'] );
198 $config['return_url'] = esc_url_raw( (string) $config['return_url'] );
199 $config['auto_select_provider'] = wpbc_booking_appointment_normalize_boolean( $config['auto_select_provider'] );
200 $config['catalog_layout'] = 'list' === sanitize_key( (string) $config['catalog_layout'] ) ? 'list' : 'grid';
201 $config['show_resource_filters'] = wpbc_booking_appointment_normalize_boolean( $config['show_resource_filters'] );
202 $config['show_resource_image'] = wpbc_booking_appointment_normalize_boolean( $config['show_resource_image'], true );
203 $config['show_resource_title'] = wpbc_booking_appointment_normalize_boolean( $config['show_resource_title'], true );
204 $config['show_resource_description'] = wpbc_booking_appointment_normalize_boolean( $config['show_resource_description'], true );
205 $config['catalog_item_width'] = wpbc_booking_appointment_normalize_css_width( $config['catalog_item_width'] );
206 $config['catalog_item_max_width'] = absint( $config['catalog_item_max_width'] );
207 if ( $config['catalog_item_max_width'] > 0 ) {
208 $config['catalog_item_max_width'] = min( 1200, max( 280, $config['catalog_item_max_width'] ) );
209 }
210 $config['catalog_grid_items_per_row'] = min( 12, absint( $config['catalog_grid_items_per_row'] ) );
211 $config['catalog_list_items_per_row'] = min( 12, absint( $config['catalog_list_items_per_row'] ) );
212 $config['show_resource_hierarchy'] = wpbc_booking_appointment_normalize_boolean( $config['show_resource_hierarchy'], true );
213 $config['show_availability'] = wpbc_booking_appointment_normalize_boolean( $config['show_availability'], true );
214 $config['show_starting_price'] = wpbc_booking_appointment_normalize_boolean( $config['show_starting_price'], true );
215 $config['show_progress'] = wpbc_booking_appointment_normalize_boolean( $config['show_progress'], true );
216 $display_text_keys = array(
217 'progress_item_1_title',
218 'progress_item_1_number',
219 'progress_item_2_title',
220 'progress_item_2_number',
221 'progress_item_3_title',
222 'progress_item_3_number',
223 'screen_1_title',
224 'screen_1_description',
225 'screen_2_title',
226 'screen_2_description',
227 );
228 foreach ( $display_text_keys as $display_text_key ) {
229 if ( null !== $config[ $display_text_key ] ) {
230 $config[ $display_text_key ] = sanitize_text_field( (string) $config[ $display_text_key ] );
231 }
232 }
233 $config['allow_past'] = wpbc_booking_appointment_normalize_boolean( $config['allow_past'] );
234
235 return (array) apply_filters( 'wpbc_booking_appointment_normalized_config', $config, $attributes );
236 }
237
238 /**
239 * Check whether signed Appointment configuration enables past bookings.
240 *
241 * The site author explicitly opts in through the shortcode. The normalized
242 * value is included in the signed Appointment context and verified again by
243 * the save handler, so a visitor cannot enable it by modifying AJAX data.
244 *
245 * @param array<string,mixed> $config Normalized or decoded Appointment configuration.
246 *
247 * @return bool True when the signed configuration explicitly enables past bookings.
248 */
249 function wpbc_booking_appointment_is_past_booking_enabled( $config ) {
250 return ! empty( $config['allow_past'] );
251 }
252
253 /**
254 * Base64-url encode a binary or text value without padding.
255 *
256 * @param string $value Value to encode.
257 *
258 * @return string URL-safe encoded value.
259 */
260 function wpbc_booking_appointment_base64url_encode( $value ) {
261 return rtrim( strtr( base64_encode( (string) $value ), '+/', '-_' ), '=' ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
262 }
263
264 /**
265 * Decode a base64-url value with strict validation.
266 *
267 * @param string $value Encoded value.
268 *
269 * @return string|false Decoded value or false.
270 */
271 function wpbc_booking_appointment_base64url_decode( $value ) {
272 $value = strtr( (string) $value, '-_', '+/' );
273 $padding = strlen( $value ) % 4;
274 if ( $padding ) {
275 $value .= str_repeat( '=', 4 - $padding );
276 }
277
278 return base64_decode( $value, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
279 }
280
281 /**
282 * Sign normalized shortcode configuration for public AJAX round trips.
283 *
284 * @param array<string,mixed> $config Normalized configuration.
285 *
286 * @return string Signed opaque configuration token.
287 */
288 function wpbc_booking_appointment_encode_config( $config ) {
289 $payload = wpbc_booking_appointment_base64url_encode( wp_json_encode( wpbc_booking_appointment_normalize_config( $config ) ) );
290 $signature = hash_hmac( 'sha256', $payload, wp_salt( 'auth' ), true );
291
292 return $payload . '.' . wpbc_booking_appointment_base64url_encode( $signature );
293 }
294
295 /**
296 * Verify and decode a public AJAX configuration token.
297 *
298 * @param string $token Signed token.
299 *
300 * @return array<string,mixed>|WP_Error Normalized configuration or validation error.
301 */
302 function wpbc_booking_appointment_decode_config( $token ) {
303 $parts = explode( '.', (string) $token, 2 );
304 if ( 2 !== count( $parts ) ) {
305 return new WP_Error( 'appointment_config_invalid', __( 'The Appointment configuration is invalid. Reload the page and try again.', 'booking' ) );
306 }
307
308 $expected_signature = hash_hmac( 'sha256', $parts[0], wp_salt( 'auth' ), true );
309 $actual_signature = wpbc_booking_appointment_base64url_decode( $parts[1] );
310 if ( false === $actual_signature || ! hash_equals( $expected_signature, $actual_signature ) ) {
311 return new WP_Error( 'appointment_config_invalid', __( 'The Appointment configuration is invalid. Reload the page and try again.', 'booking' ) );
312 }
313
314 $json = wpbc_booking_appointment_base64url_decode( $parts[0] );
315 $data = false !== $json ? json_decode( $json, true ) : null;
316 if ( ! is_array( $data ) ) {
317 return new WP_Error( 'appointment_config_invalid', __( 'The Appointment configuration is invalid. Reload the page and try again.', 'booking' ) );
318 }
319
320 return wpbc_booking_appointment_normalize_config( $data );
321 }
322
323 /**
324 * Sign one server-validated Service/Provider selection for booking submission.
325 *
326 * The selection is narrowed to exactly one Service and Provider. The token is
327 * attached to the rendered native form and verified again by the core booking
328 * save path, preventing another Appointment block from supplying its context.
329 *
330 * @param array<string,mixed> $config Original normalized shortcode configuration.
331 * @param int $service_id Selected Service ID.
332 * @param int $provider_id Selected Provider resource ID.
333 *
334 * @return string Signed selection token, or an empty string for invalid IDs.
335 */
336 function wpbc_booking_appointment_encode_submission_context( $config, $service_id, $provider_id ) {
337 $service_id = absint( $service_id );
338 $provider_id = absint( $provider_id );
339 if ( ! $service_id || ! $provider_id ) {
340 return '';
341 }
342
343 $context = wpbc_booking_appointment_normalize_config( $config );
344 $context['service_id'] = $service_id;
345 $context['provider_id'] = $provider_id;
346 $context['service_ids'] = array( $service_id );
347 $context['provider_ids'] = array( $provider_id );
348
349 return wpbc_booking_appointment_encode_config( $context );
350 }
351
352 /**
353 * Verify that a signed submission context matches the submitted booking pair.
354 *
355 * @param string $token Signed Appointment selection token.
356 * @param int $service_id Submitted Service ID.
357 * @param int $provider_id Submitted Provider resource ID.
358 *
359 * @return array<string,mixed>|WP_Error Verified context or controlled error.
360 */
361 function wpbc_booking_appointment_validate_submission_context( $token, $service_id, $provider_id ) {
362 $service_id = absint( $service_id );
363 $provider_id = absint( $provider_id );
364 if ( '' === trim( (string) $token ) ) {
365 return new WP_Error( 'appointment_context_required', __( 'The Appointment selection has expired. Please start over and try again.', 'booking' ) );
366 }
367
368 $context = wpbc_booking_appointment_decode_config( $token );
369 if ( is_wp_error( $context ) ) {
370 return new WP_Error( 'appointment_context_invalid', __( 'The Appointment selection is invalid. Please start over and try again.', 'booking' ) );
371 }
372 if ( $service_id !== absint( $context['service_id'] ) || $provider_id !== absint( $context['provider_id'] ) ) {
373 return new WP_Error( 'appointment_context_mismatch', __( 'The selected Service and Provider do not match this Appointment form. Please start over and try again.', 'booking' ) );
374 }
375
376 return $context;
377 }
378