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 / _shared-ui-catalog / class-wpbc-ui-catalog-request.php

class-wpbc-ui-catalog-request.php in Booking Calendar 11.8.4, at includes/_shared-ui-catalog/class-wpbc-ui-catalog-request.php

532 lines 21.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Shared request normalization for template-driven catalogs.
4 *
5 * @package Booking Calendar
6 * @since 11.6.0
7 */
8
9 if ( ! defined( 'ABSPATH' ) ) {
10 exit;
11 }
12
13 /**
14 * Validate catalog mechanics without interpreting domain-specific filters.
15 *
16 * Known malformed request and URL values return WP_Error. Invalid stored
17 * preferences are ignored key by key so stale configuration cannot prevent a
18 * catalog from loading. Unknown keys never cross this shared boundary.
19 */
20 final class WPBC_UI_Catalog_Request {
21
22 /**
23 * Sanitized catalog configuration used for validation.
24 *
25 * @var array
26 */
27 private $configuration = array();
28
29 /**
30 * Normalized shared request values.
31 *
32 * @var array
33 */
34 private $values = array();
35
36 /**
37 * Keys explicitly supplied by the current request or initial URL.
38 *
39 * @var array
40 */
41 private $provided_keys = array();
42
43 /**
44 * Prevent direct construction; callers must use create().
45 *
46 * @param array $configuration Sanitized catalog configuration.
47 * @param array $values Normalized shared request values.
48 * @param array $provided_keys Explicitly supplied request keys.
49 */
50 private function __construct( $configuration, $values, $provided_keys ) {
51 $this->configuration = $configuration;
52 $this->values = $values;
53 $this->provided_keys = $provided_keys;
54 }
55
56 /**
57 * Create one validated shared request.
58 *
59 * Precedence is configuration defaults, valid stored preferences, current
60 * request values, then URL overrides on the initial request only.
61 *
62 * @param array $configuration Registered catalog configuration.
63 * @param mixed $request_values Current request payload.
64 * @param mixed $stored_preferences Untrusted stored preference payload.
65 * @param mixed $url_overrides Untrusted initial URL overrides.
66 * @param bool $is_initial_request Whether URL overrides may be applied.
67 *
68 * @return WPBC_UI_Catalog_Request|WP_Error Valid request or safe error.
69 */
70 public static function create( $configuration, $request_values = array(), $stored_preferences = array(), $url_overrides = array(), $is_initial_request = false ) {
71 if ( ! is_array( $configuration ) || empty( $configuration['id'] ) ) {
72 return self::get_error( 'invalid_configuration', __( 'The catalog configuration is invalid.', 'booking' ) );
73 }
74
75 if ( ! is_array( $request_values ) || ! is_array( $url_overrides ) ) {
76 return self::get_error( 'malformed_request', __( 'The catalog request is malformed.', 'booking' ) );
77 }
78
79 $normalized_values = self::get_defaults( $configuration );
80 $provided_keys = array();
81 $preference_values = WPBC_UI_Catalog_Preferences::extract_request_values( $stored_preferences );
82 $normalized_values = self::apply_values( $normalized_values, $preference_values, $configuration, false, $provided_keys );
83 $normalized_values = self::apply_values( $normalized_values, $request_values, $configuration, true, $provided_keys );
84
85 if ( is_wp_error( $normalized_values ) ) {
86 return $normalized_values;
87 }
88
89 if ( $is_initial_request ) {
90 $normalized_values = self::apply_values( $normalized_values, $url_overrides, $configuration, true, $provided_keys );
91
92 if ( is_wp_error( $normalized_values ) ) {
93 return $normalized_values;
94 }
95 }
96
97 return new self( $configuration, $normalized_values, array_values( array_unique( $provided_keys ) ) );
98 }
99
100 /**
101 * Return the request's registered catalog identifier.
102 *
103 * @return string Catalog identifier.
104 */
105 public function get_catalog_id() {
106 return sanitize_key( (string) $this->configuration['id'] );
107 }
108
109 /**
110 * Return one normalized request value.
111 *
112 * @param string $request_key Shared request key.
113 * @param mixed $default Value returned when the key is unavailable.
114 *
115 * @return mixed Normalized value or the supplied default.
116 */
117 public function get( $request_key, $default = null ) {
118 $request_key = is_scalar( $request_key ) ? sanitize_key( (string) $request_key ) : '';
119
120 return array_key_exists( $request_key, $this->values ) ? $this->values[ $request_key ] : $default;
121 }
122
123 /**
124 * Determine whether a key was explicitly supplied for this request.
125 *
126 * @param string $request_key Shared request key.
127 *
128 * @return bool True when request or initial URL input supplied the key.
129 */
130 public function has( $request_key ) {
131 $request_key = is_scalar( $request_key ) ? sanitize_key( (string) $request_key ) : '';
132
133 return in_array( $request_key, $this->provided_keys, true );
134 }
135
136 /**
137 * Return the trusted catalog configuration used by this request.
138 *
139 * @return array Registered catalog configuration.
140 */
141 public function get_configuration() {
142 return $this->configuration;
143 }
144
145 /**
146 * Export normalized shared request values.
147 *
148 * @return array<string,mixed> Normalized request payload.
149 */
150 public function to_array() {
151 return $this->values;
152 }
153
154 /**
155 * Build safe defaults from one registered configuration.
156 *
157 * @param array $configuration Registered catalog configuration.
158 *
159 * @return array<string,mixed> Shared request defaults.
160 */
161 private static function get_defaults( $configuration ) {
162 $page_size_config = isset( $configuration['items_per_page'] ) && is_array( $configuration['items_per_page'] )
163 ? $configuration['items_per_page']
164 : array();
165 $page_size_options = self::normalize_identifier_integers( isset( $page_size_config['options'] ) ? $page_size_config['options'] : array() );
166 $maximum_page_size = isset( $page_size_config['maximum'] ) ? max( 1, absint( $page_size_config['maximum'] ) ) : 100;
167 $page_size_options = array_values(
168 array_filter(
169 $page_size_options,
170 static function ( $page_size ) use ( $maximum_page_size ) {
171 return $page_size <= $maximum_page_size;
172 }
173 )
174 );
175
176 if ( empty( $page_size_options ) ) {
177 $page_size_options = array( min( 10, $maximum_page_size ) );
178 }
179
180 $default_page_size = isset( $page_size_config['default'] ) ? absint( $page_size_config['default'] ) : $page_size_options[0];
181 if ( ! in_array( $default_page_size, $page_size_options, true ) ) {
182 $default_page_size = $page_size_options[0];
183 }
184
185 $sorting_config = isset( $configuration['sorting'] ) && is_array( $configuration['sorting'] ) ? $configuration['sorting'] : array();
186 $allowed_sorting_keys = self::normalize_identifiers( isset( $sorting_config['allowed_keys'] ) ? $sorting_config['allowed_keys'] : array() );
187 if ( empty( $allowed_sorting_keys ) ) {
188 $allowed_sorting_keys = array( 'title' );
189 }
190
191 $default_sorting_key = isset( $sorting_config['default_key'] ) ? sanitize_key( (string) $sorting_config['default_key'] ) : $allowed_sorting_keys[0];
192 if ( ! in_array( $default_sorting_key, $allowed_sorting_keys, true ) ) {
193 $default_sorting_key = $allowed_sorting_keys[0];
194 }
195
196 $default_sorting_order = isset( $sorting_config['default_order'] ) ? strtolower( (string) $sorting_config['default_order'] ) : 'asc';
197 if ( ! in_array( $default_sorting_order, array( 'asc', 'desc' ), true ) ) {
198 $default_sorting_order = 'asc';
199 }
200
201 $column_config = isset( $configuration['columns'] ) && is_array( $configuration['columns'] ) ? $configuration['columns'] : array();
202 $allowed_columns = self::normalize_identifiers( isset( $column_config['allowed'] ) ? $column_config['allowed'] : array() );
203 $default_columns = self::normalize_allowed_identifiers( isset( $column_config['default_visible'] ) ? $column_config['default_visible'] : array(), $allowed_columns );
204 $default_column_order = self::normalize_allowed_identifiers( isset( $column_config['default_order'] ) ? $column_config['default_order'] : array(), $allowed_columns );
205 $required_columns = self::normalize_allowed_identifiers( isset( $column_config['required'] ) ? $column_config['required'] : array(), $allowed_columns );
206 $default_columns = array_values( array_unique( array_merge( $default_columns, $required_columns ) ) );
207 $default_column_order = self::complete_column_order( $default_column_order, $allowed_columns, $column_config );
208 $available_template_packs = isset( $configuration['template_packs'] ) && is_array( $configuration['template_packs'] )
209 ? self::normalize_identifiers( array_keys( $configuration['template_packs'] ) )
210 : array();
211
212 if ( empty( $available_template_packs ) ) {
213 $available_template_packs = array( 'table' );
214 }
215
216 $default_template_pack = isset( $configuration['default_template_pack'] ) ? sanitize_key( (string) $configuration['default_template_pack'] ) : $available_template_packs[0];
217 if ( ! in_array( $default_template_pack, $available_template_packs, true ) ) {
218 $default_template_pack = $available_template_packs[0];
219 }
220
221 return array(
222 'request_id' => 0,
223 'page_number' => 1,
224 'items_per_page' => $default_page_size,
225 'sort_by' => $default_sorting_key,
226 'sort_order' => $default_sorting_order,
227 'search' => '',
228 'visible_columns' => $default_columns,
229 'column_order' => $default_column_order,
230 'template_pack' => $default_template_pack,
231 );
232 }
233
234 /**
235 * Apply one untrusted value layer to normalized request values.
236 *
237 * @param array $normalized_values Current normalized values.
238 * @param array $candidate_values Untrusted candidate values.
239 * @param array $configuration Registered catalog configuration.
240 * @param bool $reject_invalid Whether invalid values return WP_Error.
241 * @param array $provided_keys Explicit key collection passed by reference.
242 *
243 * @return array|WP_Error Updated values or safe error.
244 */
245 private static function apply_values( $normalized_values, $candidate_values, $configuration, $reject_invalid, &$provided_keys ) {
246 $known_keys = array_keys( $normalized_values );
247
248 foreach ( $known_keys as $request_key ) {
249 if ( ! array_key_exists( $request_key, $candidate_values ) ) {
250 continue;
251 }
252
253 $normalized_value = self::normalize_value( $request_key, $candidate_values[ $request_key ], $configuration );
254 if ( is_wp_error( $normalized_value ) ) {
255 if ( $reject_invalid ) {
256 return $normalized_value;
257 }
258 continue;
259 }
260
261 $normalized_values[ $request_key ] = $normalized_value;
262 if ( $reject_invalid ) {
263 $provided_keys[] = $request_key;
264 }
265 }
266
267 return $normalized_values;
268 }
269
270 /**
271 * Normalize one known shared request value.
272 *
273 * @param string $request_key Shared request key.
274 * @param mixed $request_value Untrusted request value.
275 * @param array $configuration Registered catalog configuration.
276 *
277 * @return mixed|WP_Error Normalized value or safe error.
278 */
279 private static function normalize_value( $request_key, $request_value, $configuration ) {
280 switch ( $request_key ) {
281 case 'request_id':
282 return self::normalize_integer( $request_value, 0, PHP_INT_MAX, $request_key );
283
284 case 'page_number':
285 return self::normalize_integer( $request_value, 1, PHP_INT_MAX, $request_key );
286
287 case 'items_per_page':
288 $page_size = self::normalize_integer( $request_value, 1, PHP_INT_MAX, $request_key );
289 if ( is_wp_error( $page_size ) ) {
290 return $page_size;
291 }
292
293 $page_size_config = isset( $configuration['items_per_page'] ) && is_array( $configuration['items_per_page'] ) ? $configuration['items_per_page'] : array();
294 $maximum_page_size = isset( $page_size_config['maximum'] ) ? max( 1, absint( $page_size_config['maximum'] ) ) : 100;
295 $allowed_page_sizes = self::normalize_identifier_integers( isset( $page_size_config['options'] ) ? $page_size_config['options'] : array() );
296 if ( $page_size > $maximum_page_size || ! in_array( $page_size, $allowed_page_sizes, true ) ) {
297 return self::get_error( 'invalid_items_per_page', __( 'The requested catalog page size is not available.', 'booking' ) );
298 }
299 return $page_size;
300
301 case 'sort_by':
302 if ( ! is_scalar( $request_value ) ) {
303 return self::get_error( 'invalid_sort_by', __( 'The requested catalog sorting field is invalid.', 'booking' ) );
304 }
305 $sorting_key = sanitize_key( (string) $request_value );
306 $sorting_config = isset( $configuration['sorting'] ) && is_array( $configuration['sorting'] ) ? $configuration['sorting'] : array();
307 $allowed_sorting_keys = self::normalize_identifiers( isset( $sorting_config['allowed_keys'] ) ? $sorting_config['allowed_keys'] : array() );
308 return in_array( $sorting_key, $allowed_sorting_keys, true )
309 ? $sorting_key
310 : self::get_error( 'invalid_sort_by', __( 'The requested catalog sorting field is invalid.', 'booking' ) );
311
312 case 'sort_order':
313 if ( ! is_scalar( $request_value ) ) {
314 return self::get_error( 'invalid_sort_order', __( 'The requested catalog sorting direction is invalid.', 'booking' ) );
315 }
316 $sorting_order = strtolower( trim( (string) $request_value ) );
317 return in_array( $sorting_order, array( 'asc', 'desc' ), true )
318 ? $sorting_order
319 : self::get_error( 'invalid_sort_order', __( 'The requested catalog sorting direction is invalid.', 'booking' ) );
320
321 case 'search':
322 if ( ! is_scalar( $request_value ) ) {
323 return self::get_error( 'invalid_search', __( 'The catalog search value is invalid.', 'booking' ) );
324 }
325 $search_value = sanitize_text_field( (string) $request_value );
326 return function_exists( 'mb_substr' ) ? mb_substr( $search_value, 0, 200 ) : substr( $search_value, 0, 200 );
327
328 case 'visible_columns':
329 $column_config = isset( $configuration['columns'] ) && is_array( $configuration['columns'] ) ? $configuration['columns'] : array();
330 $allowed_columns = self::normalize_identifiers( isset( $column_config['allowed'] ) ? $column_config['allowed'] : array() );
331 $visible_columns = self::normalize_identifier_list( $request_value, $allowed_columns, $request_key );
332 if ( is_wp_error( $visible_columns ) ) {
333 return $visible_columns;
334 }
335 $required_columns = self::normalize_allowed_identifiers( isset( $column_config['required'] ) ? $column_config['required'] : array(), $allowed_columns );
336 return array_values( array_unique( array_merge( $visible_columns, $required_columns ) ) );
337
338 case 'column_order':
339 $column_config = isset( $configuration['columns'] ) && is_array( $configuration['columns'] ) ? $configuration['columns'] : array();
340 $allowed_columns = self::normalize_identifiers( isset( $column_config['allowed'] ) ? $column_config['allowed'] : array() );
341 $column_order = self::normalize_identifier_list( $request_value, $allowed_columns, $request_key );
342 return is_wp_error( $column_order ) ? $column_order : self::complete_column_order( $column_order, $allowed_columns, $column_config );
343
344 case 'template_pack':
345 if ( ! is_scalar( $request_value ) ) {
346 return self::get_error( 'invalid_template_pack', __( 'The requested catalog layout is invalid.', 'booking' ) );
347 }
348 $template_pack = sanitize_key( (string) $request_value );
349 $allowed_template_packs = isset( $configuration['template_packs'] ) && is_array( $configuration['template_packs'] )
350 ? self::normalize_identifiers( array_keys( $configuration['template_packs'] ) )
351 : array();
352 return in_array( $template_pack, $allowed_template_packs, true )
353 ? $template_pack
354 : self::get_error( 'invalid_template_pack', __( 'The requested catalog layout is invalid.', 'booking' ) );
355 }
356
357 return self::get_error( 'unsupported_request_key', __( 'The catalog request contains an unsupported value.', 'booking' ) );
358 }
359
360 /**
361 * Normalize one bounded integer request value.
362 *
363 * @param mixed $request_value Untrusted value.
364 * @param int $minimum Inclusive minimum.
365 * @param int $maximum Inclusive maximum.
366 * @param string $request_key Key used for a safe error code.
367 *
368 * @return int|WP_Error Bounded integer or safe error.
369 */
370 private static function normalize_integer( $request_value, $minimum, $maximum, $request_key ) {
371 if ( ! is_scalar( $request_value ) || ! preg_match( '/^-?\d+$/', trim( (string) $request_value ) ) ) {
372 return self::get_error( 'invalid_' . sanitize_key( $request_key ), __( 'A numeric catalog request value is invalid.', 'booking' ) );
373 }
374
375 $request_value = (int) $request_value;
376 if ( $minimum > $request_value || $maximum < $request_value ) {
377 return self::get_error( 'invalid_' . sanitize_key( $request_key ), __( 'A numeric catalog request value is outside the accepted range.', 'booking' ) );
378 }
379
380 return $request_value;
381 }
382
383 /**
384 * Normalize and validate an identifier list.
385 *
386 * @param mixed $request_value Untrusted list.
387 * @param array $allowed_identifiers Allow-listed identifiers.
388 * @param string $request_key Key used for a safe error code.
389 *
390 * @return array|WP_Error Normalized unique identifiers or safe error.
391 */
392 private static function normalize_identifier_list( $request_value, $allowed_identifiers, $request_key ) {
393 if ( ! is_array( $request_value ) ) {
394 return self::get_error( 'invalid_' . sanitize_key( $request_key ), __( 'A catalog display preference is malformed.', 'booking' ) );
395 }
396
397 $normalized_identifiers = array();
398 foreach ( $request_value as $identifier ) {
399 if ( ! is_scalar( $identifier ) ) {
400 return self::get_error( 'invalid_' . sanitize_key( $request_key ), __( 'A catalog display preference is malformed.', 'booking' ) );
401 }
402
403 $identifier = sanitize_key( (string) $identifier );
404 if ( '' === $identifier || ! in_array( $identifier, $allowed_identifiers, true ) || in_array( $identifier, $normalized_identifiers, true ) ) {
405 return self::get_error( 'invalid_' . sanitize_key( $request_key ), __( 'A catalog display preference contains an unsupported value.', 'booking' ) );
406 }
407
408 $normalized_identifiers[] = $identifier;
409 }
410
411 return $normalized_identifiers;
412 }
413
414 /**
415 * Complete a column order while retaining fixed columns in default slots.
416 *
417 * @param array $requested_order Requested allow-listed order.
418 * @param array $allowed_columns Complete allow-listed column collection.
419 * @param array $column_config Registered column configuration.
420 *
421 * @return array<int,string> Complete normalized column order.
422 */
423 private static function complete_column_order( $requested_order, $allowed_columns, $column_config ) {
424 $default_order = self::normalize_allowed_identifiers(
425 isset( $column_config['default_order'] ) ? $column_config['default_order'] : array(),
426 $allowed_columns
427 );
428 foreach ( $allowed_columns as $column_id ) {
429 if ( ! in_array( $column_id, $default_order, true ) ) {
430 $default_order[] = $column_id;
431 }
432 if ( ! in_array( $column_id, $requested_order, true ) ) {
433 $requested_order[] = $column_id;
434 }
435 }
436
437 $definitions = isset( $column_config['definitions'] ) && is_array( $column_config['definitions'] ) ? $column_config['definitions'] : array();
438 $movable_order = array_values(
439 array_filter(
440 $requested_order,
441 static function ( $column_id ) use ( $definitions ) {
442 return ! isset( $definitions[ $column_id ]['reorderable'] ) || ! empty( $definitions[ $column_id ]['reorderable'] );
443 }
444 )
445 );
446 $movable_index = 0;
447
448 return array_map(
449 static function ( $column_id ) use ( $definitions, $movable_order, &$movable_index ) {
450 if ( isset( $definitions[ $column_id ]['reorderable'] ) && empty( $definitions[ $column_id ]['reorderable'] ) ) {
451 return $column_id;
452 }
453
454 return isset( $movable_order[ $movable_index ] ) ? $movable_order[ $movable_index++ ] : $column_id;
455 },
456 $default_order
457 );
458 }
459
460 /**
461 * Normalize scalar identifiers while silently dropping invalid values.
462 *
463 * @param mixed $identifiers Raw identifier collection.
464 *
465 * @return array<int,string> Sanitized unique identifiers.
466 */
467 private static function normalize_identifiers( $identifiers ) {
468 $normalized_identifiers = array();
469
470 if ( ! is_array( $identifiers ) ) {
471 return $normalized_identifiers;
472 }
473
474 foreach ( $identifiers as $identifier ) {
475 $identifier = is_scalar( $identifier ) ? sanitize_key( (string) $identifier ) : '';
476 if ( '' !== $identifier && ! in_array( $identifier, $normalized_identifiers, true ) ) {
477 $normalized_identifiers[] = $identifier;
478 }
479 }
480
481 return $normalized_identifiers;
482 }
483
484 /**
485 * Normalize integer configuration values.
486 *
487 * @param mixed $integers Raw integer collection.
488 *
489 * @return array<int,int> Positive unique integers.
490 */
491 private static function normalize_identifier_integers( $integers ) {
492 $normalized_integers = array();
493
494 if ( ! is_array( $integers ) ) {
495 return $normalized_integers;
496 }
497
498 foreach ( $integers as $integer ) {
499 $integer = is_scalar( $integer ) ? absint( $integer ) : 0;
500 if ( 0 < $integer && ! in_array( $integer, $normalized_integers, true ) ) {
501 $normalized_integers[] = $integer;
502 }
503 }
504
505 return $normalized_integers;
506 }
507
508 /**
509 * Normalize defaults against an allow-list without returning errors.
510 *
511 * @param mixed $identifiers Raw default identifiers.
512 * @param array $allowed_identifiers Allowed identifiers.
513 *
514 * @return array<int,string> Valid default identifiers.
515 */
516 private static function normalize_allowed_identifiers( $identifiers, $allowed_identifiers ) {
517 return array_values( array_intersect( self::normalize_identifiers( $identifiers ), $allowed_identifiers ) );
518 }
519
520 /**
521 * Create a namespaced, non-sensitive request error.
522 *
523 * @param string $error_code Short error code.
524 * @param string $error_message User-facing error message.
525 *
526 * @return WP_Error Request error.
527 */
528 private static function get_error( $error_code, $error_message ) {
529 return new WP_Error( 'wpbc_ui_catalog_' . sanitize_key( $error_code ), $error_message );
530 }
531 }
532