PluginProbe
Booking Calendar / 11.6.1
Booking Calendar v11.6.1
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 / _shared-ui-catalog / class-wpbc-ui-catalog-request.php

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

529 lines 20.9 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
377 return max( $minimum, min( $maximum, $request_value ) );
378 }
379
380 /**
381 * Normalize and validate an identifier list.
382 *
383 * @param mixed $request_value Untrusted list.
384 * @param array $allowed_identifiers Allow-listed identifiers.
385 * @param string $request_key Key used for a safe error code.
386 *
387 * @return array|WP_Error Normalized unique identifiers or safe error.
388 */
389 private static function normalize_identifier_list( $request_value, $allowed_identifiers, $request_key ) {
390 if ( ! is_array( $request_value ) ) {
391 return self::get_error( 'invalid_' . sanitize_key( $request_key ), __( 'A catalog display preference is malformed.', 'booking' ) );
392 }
393
394 $normalized_identifiers = array();
395 foreach ( $request_value as $identifier ) {
396 if ( ! is_scalar( $identifier ) ) {
397 return self::get_error( 'invalid_' . sanitize_key( $request_key ), __( 'A catalog display preference is malformed.', 'booking' ) );
398 }
399
400 $identifier = sanitize_key( (string) $identifier );
401 if ( '' === $identifier || ! in_array( $identifier, $allowed_identifiers, true ) || in_array( $identifier, $normalized_identifiers, true ) ) {
402 return self::get_error( 'invalid_' . sanitize_key( $request_key ), __( 'A catalog display preference contains an unsupported value.', 'booking' ) );
403 }
404
405 $normalized_identifiers[] = $identifier;
406 }
407
408 return $normalized_identifiers;
409 }
410
411 /**
412 * Complete a column order while retaining fixed columns in default slots.
413 *
414 * @param array $requested_order Requested allow-listed order.
415 * @param array $allowed_columns Complete allow-listed column collection.
416 * @param array $column_config Registered column configuration.
417 *
418 * @return array<int,string> Complete normalized column order.
419 */
420 private static function complete_column_order( $requested_order, $allowed_columns, $column_config ) {
421 $default_order = self::normalize_allowed_identifiers(
422 isset( $column_config['default_order'] ) ? $column_config['default_order'] : array(),
423 $allowed_columns
424 );
425 foreach ( $allowed_columns as $column_id ) {
426 if ( ! in_array( $column_id, $default_order, true ) ) {
427 $default_order[] = $column_id;
428 }
429 if ( ! in_array( $column_id, $requested_order, true ) ) {
430 $requested_order[] = $column_id;
431 }
432 }
433
434 $definitions = isset( $column_config['definitions'] ) && is_array( $column_config['definitions'] ) ? $column_config['definitions'] : array();
435 $movable_order = array_values(
436 array_filter(
437 $requested_order,
438 static function ( $column_id ) use ( $definitions ) {
439 return ! isset( $definitions[ $column_id ]['reorderable'] ) || ! empty( $definitions[ $column_id ]['reorderable'] );
440 }
441 )
442 );
443 $movable_index = 0;
444
445 return array_map(
446 static function ( $column_id ) use ( $definitions, $movable_order, &$movable_index ) {
447 if ( isset( $definitions[ $column_id ]['reorderable'] ) && empty( $definitions[ $column_id ]['reorderable'] ) ) {
448 return $column_id;
449 }
450
451 return isset( $movable_order[ $movable_index ] ) ? $movable_order[ $movable_index++ ] : $column_id;
452 },
453 $default_order
454 );
455 }
456
457 /**
458 * Normalize scalar identifiers while silently dropping invalid values.
459 *
460 * @param mixed $identifiers Raw identifier collection.
461 *
462 * @return array<int,string> Sanitized unique identifiers.
463 */
464 private static function normalize_identifiers( $identifiers ) {
465 $normalized_identifiers = array();
466
467 if ( ! is_array( $identifiers ) ) {
468 return $normalized_identifiers;
469 }
470
471 foreach ( $identifiers as $identifier ) {
472 $identifier = is_scalar( $identifier ) ? sanitize_key( (string) $identifier ) : '';
473 if ( '' !== $identifier && ! in_array( $identifier, $normalized_identifiers, true ) ) {
474 $normalized_identifiers[] = $identifier;
475 }
476 }
477
478 return $normalized_identifiers;
479 }
480
481 /**
482 * Normalize integer configuration values.
483 *
484 * @param mixed $integers Raw integer collection.
485 *
486 * @return array<int,int> Positive unique integers.
487 */
488 private static function normalize_identifier_integers( $integers ) {
489 $normalized_integers = array();
490
491 if ( ! is_array( $integers ) ) {
492 return $normalized_integers;
493 }
494
495 foreach ( $integers as $integer ) {
496 $integer = is_scalar( $integer ) ? absint( $integer ) : 0;
497 if ( 0 < $integer && ! in_array( $integer, $normalized_integers, true ) ) {
498 $normalized_integers[] = $integer;
499 }
500 }
501
502 return $normalized_integers;
503 }
504
505 /**
506 * Normalize defaults against an allow-list without returning errors.
507 *
508 * @param mixed $identifiers Raw default identifiers.
509 * @param array $allowed_identifiers Allowed identifiers.
510 *
511 * @return array<int,string> Valid default identifiers.
512 */
513 private static function normalize_allowed_identifiers( $identifiers, $allowed_identifiers ) {
514 return array_values( array_intersect( self::normalize_identifiers( $identifiers ), $allowed_identifiers ) );
515 }
516
517 /**
518 * Create a namespaced, non-sensitive request error.
519 *
520 * @param string $error_code Short error code.
521 * @param string $error_message User-facing error message.
522 *
523 * @return WP_Error Request error.
524 */
525 private static function get_error( $error_code, $error_message ) {
526 return new WP_Error( 'wpbc_ui_catalog_' . sanitize_key( $error_code ), $error_message );
527 }
528 }
529