PluginProbe
Parse.ly / 3.24.3
Parse.ly v3.24.3
3.24.3 3.24.2 3.24.1 3.24.0 3.23.7 3.23.6 3.23.5 3.23.4 3.23.3 3.16.0 3.16.1 3.16.2 3.16.3 3.16.4 3.17.0 3.18.0 3.18.1 3.19.0 3.19.1 3.19.2 3.19.3 3.2.0 3.2.1 3.20.0 3.20.1 All 107 releases
wp-parsely / src / rest-api / settings / class-base-settings-endpoint.php

class-base-settings-endpoint.php in Parse.ly 3.24.3, at src/rest-api/settings/class-base-settings-endpoint.php

701 lines 18.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Base class for all settings endpoints.
4 *
5 * @package Parsely
6 * @since 3.13.0
7 */
8
9 declare(strict_types=1);
10
11 namespace Parsely\REST_API\Settings;
12
13 use Parsely\REST_API\Base_API_Controller;
14 use Parsely\REST_API\Base_Endpoint;
15 use WP_REST_Request;
16 use WP_REST_Response;
17 use WP_Error;
18
19 /**
20 * Base class for all settings endpoints.
21 *
22 * Settings endpoints are used to manage user meta data.
23 *
24 * @since 3.13.0
25 *
26 * @phpstan-type Subvalue_Spec array{values: array<mixed>, default: mixed}
27 */
28 abstract class Base_Settings_Endpoint extends Base_Endpoint {
29 /**
30 * The format that stored settings are expected to be in. Recorded per user,
31 * and bumped whenever stored values need converting.
32 *
33 * @since 3.24.1
34 *
35 * @var int
36 */
37 protected const STORED_FORMAT_VERSION = 1;
38
39 /**
40 * The meta entry's default value. Initialized in the constructor.
41 *
42 * @since 3.13.0
43 * @since 3.17.0 Moved from Base_Endpoint_User_Meta.
44 *
45 * @var array<string, mixed>
46 */
47 protected $default_value = array();
48
49 /**
50 * The valid values that can be used for each subvalue. Initialized in the
51 * constructor.
52 *
53 * @since 3.13.0
54 * @since 3.17.0 Moved from Base_Endpoint_User_Meta.
55 *
56 * @var array<string, array<int, mixed>>
57 */
58 protected $valid_subvalues = array();
59
60 /**
61 * Returns the meta entry's key.
62 *
63 * @since 3.13.0
64 * @since 3.17.0 Moved from Base_Endpoint_User_Meta.
65 *
66 * @return string The meta entry's key.
67 */
68 abstract protected function get_meta_key(): string;
69
70 /**
71 * Returns the endpoint's subvalues specifications.
72 *
73 * @since 3.13.0
74 * @since 3.17.0 Moved from Base_Endpoint_User_Meta.
75 *
76 * @return array<string, Subvalue_Spec>
77 */
78 abstract protected function get_subvalues_specs(): array;
79
80 /**
81 * Returns the composite keys of the settings that inherit their default.
82 *
83 * These are stored only once the user deviates from the default, so that a
84 * later change to it still reaches users who never set them.
85 *
86 * @since 3.24.1
87 *
88 * @return array<string> The composite keys.
89 */
90 protected function get_inheritable_keys(): array {
91 return array();
92 }
93
94 /**
95 * Returns the defaults that inheritable settings had before they became
96 * inheritable, keyed by composite key.
97 *
98 * A stored value matching one of these was written when the setting had no
99 * configurable default, so it cannot be told apart from an untouched one.
100 *
101 * @since 3.24.1
102 *
103 * @return array<string, mixed> The defaults, as composite key => value pairs.
104 */
105 protected function get_legacy_defaults(): array {
106 return array();
107 }
108
109 /**
110 * Constructor.
111 *
112 * @since 3.13.0
113 * @since 3.17.0 Moved from Base_Endpoint_User_Meta.
114 *
115 * @param Base_API_Controller $controller The REST API controller.
116 */
117 public function __construct( Base_API_Controller $controller ) {
118 parent::__construct( $controller );
119
120 $subvalues_specs = $this->get_subvalues_specs();
121
122 foreach ( $subvalues_specs as $key => $value ) {
123 $this->default_value[ $key ] = $value['default'];
124 $this->valid_subvalues[ $key ] = $value['values'];
125 }
126 }
127
128 /**
129 * Registers the routes for the endpoint.
130 *
131 * @since 3.17.0
132 */
133 public function register_routes(): void {
134 /**
135 * GET settings/{endpoint}/get
136 * Retrieves the settings for the current user.
137 */
138 $this->register_rest_route(
139 '/get',
140 array( 'GET' ),
141 array( $this, 'get_settings' )
142 );
143
144 /**
145 * PUT settings/{endpoint}/set
146 * Updates the settings for the current user.
147 */
148 $this->register_rest_route(
149 '/set',
150 array( 'PUT' ),
151 array( $this, 'set_settings' )
152 );
153
154 /**
155 * GET|PUT settings/{endpoint}
156 * Handles direct requests to the endpoint.
157 */
158 $this->register_rest_route(
159 '/',
160 array( 'GET', 'PUT' ),
161 array( $this, 'process_request' )
162 );
163 }
164
165 /**
166 * API Endpoint: GET|PUT settings/{endpoint}/
167 *
168 * Processes the requests sent directly to the main endpoint.
169 *
170 * @since 3.17.0
171 *
172 * @param WP_REST_Request $request The request sent to the endpoint.
173 * @return WP_REST_Response|WP_Error The response object.
174 */
175 public function process_request( WP_REST_Request $request ) {
176 $request_method = $request->get_method();
177
178 // Update the meta entry's value if the request method is PUT.
179 if ( 'PUT' === $request_method ) {
180 return $this->set_settings( $request );
181 }
182
183 return $this->get_settings();
184 }
185
186 /**
187 * API Endpoint: GET settings/{endpoint}/get
188 *
189 * Retrieves the settings for the current user.
190 *
191 * The stored value is resolved against the current specifications, so that
192 * a setting added after the user's last save still reaches them.
193 *
194 * @since 3.17.0
195 * @since 3.24.0 The stored value is resolved against the current
196 * specifications.
197 * @since 3.24.1 A stored value predating the current format is normalized
198 * and stored anew.
199 *
200 * @return WP_REST_Response The response object.
201 */
202 public function get_settings(): WP_REST_Response {
203 return new WP_REST_Response(
204 $this->sanitize_value( $this->get_stored_settings() ),
205 200
206 );
207 }
208
209 /**
210 * API Endpoint: PUT settings/{endpoint}/set
211 *
212 * Updates the settings for the current user.
213 *
214 * Settings absent from the request keep their current value, rather than
215 * being reset to their default.
216 *
217 * @since 3.17.0
218 * @since 3.24.1 The request is merged into the current settings, and
219 * inheritable settings are stored only once set.
220 *
221 * @param WP_REST_Request $request The request object.
222 * @return WP_REST_Response|WP_Error The response object.
223 */
224 public function set_settings( WP_REST_Request $request ) {
225 $meta_value = $request->get_json_params();
226
227 // Validates the settings format.
228 if ( ! is_array( $meta_value ) ) { // @phpstan-ignore-line
229 return new WP_Error(
230 'ch_settings_invalid_format',
231 __( 'Settings must be a valid JSON array', 'wp-parsely' )
232 );
233 }
234
235 $stored_settings = $this->get_stored_settings();
236 $current_settings = $this->sanitize_value( $stored_settings );
237 $sanitized_value = $this->sanitize_value(
238 $this->merge_settings( $meta_value, $current_settings )
239 );
240
241 // If the current settings are the same as the new settings, return early.
242 if ( $current_settings === $sanitized_value ) {
243 return new WP_REST_Response( $current_settings, 200 );
244 }
245
246 $update_meta = update_user_meta(
247 get_current_user_id(),
248 $this->get_meta_key(),
249 $this->strip_inherited_values( $sanitized_value, $stored_settings )
250 );
251
252 if ( false === $update_meta ) {
253 return new WP_Error(
254 'ch_settings_update_failed',
255 __( 'Failed to update settings', 'wp-parsely' )
256 );
257 }
258
259 $this->record_stored_format();
260
261 return new WP_REST_Response( $sanitized_value, 200 );
262 }
263
264 /**
265 * Returns the current user's stored settings, normalizing them first if
266 * they were stored in an earlier format.
267 *
268 * @since 3.24.1
269 *
270 * @return array<string, mixed> The stored settings.
271 */
272 protected function get_stored_settings(): array {
273 $settings = get_user_meta( get_current_user_id(), $this->get_meta_key(), true );
274 $settings = is_array( $settings ) ? $settings : array();
275
276 return $this->needs_normalizing( $settings )
277 ? $this->normalize_stored_settings( $settings )
278 : $settings;
279 }
280
281 /**
282 * Returns whether the passed stored settings need normalizing before use.
283 *
284 * @since 3.24.1
285 *
286 * @param array<string, mixed> $settings The stored settings.
287 * @return bool
288 */
289 protected function needs_normalizing( array $settings ): bool {
290 if ( 0 === count( $settings ) || 0 === count( $this->get_inheritable_keys() ) ) {
291 return false;
292 }
293
294 $format = get_user_meta( get_current_user_id(), $this->get_format_meta_key(), true );
295
296 return static::STORED_FORMAT_VERSION !== ( is_numeric( $format ) ? (int) $format : 0 );
297 }
298
299 /**
300 * Drops the stored inheritable settings that cannot be told apart from an
301 * untouched default, and stores the result.
302 *
303 * A value matching the setting's current default was stored by a full-tree
304 * save; one matching its legacy default was stored when the setting had no
305 * configurable default. Neither expresses a choice.
306 *
307 * @since 3.24.1
308 *
309 * @param array<string, mixed> $settings The stored settings.
310 * @return array<string, mixed> The normalized settings.
311 */
312 protected function normalize_stored_settings( array $settings ): array {
313 $legacy_defaults = $this->get_legacy_defaults();
314
315 foreach ( $this->get_inheritable_keys() as $composite_key ) {
316 $keys = explode( '.', $composite_key );
317 $value = $this->get_path_value( $settings, $keys );
318
319 if ( null === $value ) {
320 continue;
321 }
322
323 $defaults = array( $this->get_default( $keys ) );
324 if ( array_key_exists( $composite_key, $legacy_defaults ) ) {
325 $defaults[] = $legacy_defaults[ $composite_key ];
326 }
327
328 if ( in_array( $value, $defaults, true ) ) {
329 $this->unset_path_value( $settings, $keys );
330 }
331 }
332
333 update_user_meta( get_current_user_id(), $this->get_meta_key(), $settings );
334 $this->record_stored_format();
335
336 return $settings;
337 }
338
339 /**
340 * Removes the inheritable settings that the user has not set, so that they
341 * keep following their default.
342 *
343 * A setting counts as set once it is stored, so that a choice is not undone
344 * by a later change to the default that happens to match it.
345 *
346 * @since 3.24.1
347 *
348 * @param array<string, mixed> $settings The settings about to be stored.
349 * @param array<string, mixed> $stored The settings as they are stored.
350 * @return array<string, mixed> The settings to store.
351 */
352 protected function strip_inherited_values( array $settings, array $stored ): array {
353 foreach ( $this->get_inheritable_keys() as $composite_key ) {
354 $keys = explode( '.', $composite_key );
355
356 if ( $this->is_stored_override( $stored, $composite_key ) ||
357 $this->get_path_value( $settings, $keys ) !== $this->get_default( $keys )
358 ) {
359 continue;
360 }
361
362 $this->unset_path_value( $settings, $keys );
363 }
364
365 return $settings;
366 }
367
368 /**
369 * Returns whether the user set the passed setting.
370 *
371 * A value that sanitization rejects resolves to the setting's default on
372 * read, so treating it as set would pin the user to that default.
373 *
374 * @since 3.24.1
375 *
376 * @param array<string, mixed> $stored The settings as they are stored.
377 * @param string $composite_key The setting's composite key.
378 * @return bool
379 */
380 protected function is_stored_override( array $stored, string $composite_key ): bool {
381 $value = $this->get_path_value( $stored, explode( '.', $composite_key ) );
382
383 return null !== $value && $this->sanitize_subvalue( $composite_key, $value ) === $value;
384 }
385
386 /**
387 * Records that the current user's settings are stored in the current
388 * format.
389 *
390 * @since 3.24.1
391 */
392 protected function record_stored_format(): void {
393 if ( 0 === count( $this->get_inheritable_keys() ) ) {
394 return;
395 }
396
397 update_user_meta(
398 get_current_user_id(),
399 $this->get_format_meta_key(),
400 static::STORED_FORMAT_VERSION
401 );
402 }
403
404 /**
405 * Returns the key of the meta entry holding the stored format's version.
406 *
407 * @since 3.24.1
408 *
409 * @return string The meta entry's key.
410 */
411 protected function get_format_meta_key(): string {
412 return $this->get_meta_key() . '_format';
413 }
414
415 /**
416 * Returns whether the endpoint is available for access by the current
417 * user.
418 *
419 * @since 3.14.0
420 * @since 3.16.0 Added the `$request` parameter.
421 * @since 3.17.0 Moved from Base_Endpoint_User_Meta.
422 *
423 * @param WP_REST_Request|null $request The request object.
424 * @return bool
425 */
426 public function is_available_to_current_user( ?WP_REST_Request $request = null ): bool {
427 return current_user_can( 'edit_user', get_current_user_id() );
428 }
429
430 /**
431 * Sanitizes the passed meta value.
432 *
433 * @since 3.13.0
434 * @since 3.14.0 Added support for nested arrays.
435 * @since 3.17.0 Moved from Base_Endpoint_User_Meta.
436 *
437 * @param array<string, mixed> $meta_value The meta value to sanitize.
438 * @param string $parent_key The parent key for the current level of the meta.
439 * @return array<string, mixed> The sanitized meta as an array of subvalues.
440 */
441 protected function sanitize_value( array $meta_value, string $parent_key = '' ): array {
442 $sanitized_value = array();
443
444 // Determine the current level's specifications based on the parent key.
445 /**
446 * Current level's specifications.
447 *
448 * @var array<string, Subvalue_Spec> $current_specs
449 */
450 $current_specs = ( '' === $parent_key ) ? $this->get_subvalues_specs() : $this->get_nested_specs( $parent_key );
451
452 foreach ( $current_specs as $key => $spec ) {
453 $composite_key = '' === $parent_key ? $key : $parent_key . '.' . $key;
454
455 // Check if the key exists in the input meta value array.
456 if ( array_key_exists( $key, $meta_value ) ) {
457 $value = $meta_value[ $key ];
458 } else {
459 // Key is missing in the input, use the default value from the specifications.
460 $value = $this->get_default( explode( '.', $composite_key ) );
461 }
462
463 /**
464 * Spec for the current key.
465 *
466 * @var array{default: mixed, values?: array<mixed>} $spec
467 */
468 if ( is_array( $value ) && isset( $spec['values'] ) ) {
469 // Recursively handle nested arrays if 'values' spec exists for this key.
470 $sanitized_value[ $key ] = $this->sanitize_value( $value, $composite_key );
471 } else {
472 // Directly sanitize non-array values or non-nested specs.
473 $sanitized_value[ $key ] = $this->sanitize_subvalue( $composite_key, $value );
474 }
475 }
476
477 return $sanitized_value;
478 }
479
480 /**
481 * Merges the passed settings into the current ones.
482 *
483 * Recursion follows the specifications, so that a setting holding a list of
484 * values is replaced rather than merged into.
485 *
486 * @since 3.24.1
487 *
488 * @param array<string, mixed> $settings The settings to merge in.
489 * @param array<string, mixed> $current The settings to merge into.
490 * @param string $parent_key The parent key for the current level of the settings.
491 * @return array<string, mixed> The merged settings.
492 */
493 protected function merge_settings(
494 array $settings,
495 array $current,
496 string $parent_key = ''
497 ): array {
498 /**
499 * Current level's specifications.
500 *
501 * @var array<string, Subvalue_Spec> $current_specs
502 */
503 $current_specs = ( '' === $parent_key ) ? $this->get_subvalues_specs() : $this->get_nested_specs( $parent_key );
504
505 foreach ( $current_specs as $key => $spec ) {
506 if ( ! array_key_exists( $key, $settings ) ) {
507 continue;
508 }
509
510 $value = $settings[ $key ];
511
512 /**
513 * Spec for the current key.
514 *
515 * @var array{default: mixed, values?: array<mixed>} $spec
516 */
517 if ( is_array( $value ) && isset( $spec['values'] ) ) {
518 $current_value = $current[ $key ] ?? null;
519 $current[ $key ] = $this->merge_settings(
520 $value,
521 is_array( $current_value ) ? $current_value : array(),
522 '' === $parent_key ? $key : $parent_key . '.' . $key
523 );
524 } else {
525 $current[ $key ] = $value;
526 }
527 }
528
529 return $current;
530 }
531
532 /**
533 * Sanitizes the passed subvalue.
534 *
535 * @since 3.13.0
536 * @since 3.14.0 Added support for nested arrays.
537 * @since 3.17.0 Moved from Base_Endpoint_User_Meta.
538 *
539 * @param string $composite_key The subvalue's key.
540 * @param mixed $value The value to sanitize.
541 * @return mixed The sanitized subvalue.
542 */
543 protected function sanitize_subvalue( string $composite_key, $value ) {
544 $keys = explode( '.', $composite_key );
545 $valid_values = $this->get_valid_values( $keys );
546
547 if ( is_array( $value ) ) {
548 // Check if $value elements are inside $valid_values
549 // If not, the value should be the default value.
550 $valid_value = array();
551 foreach ( $value as $key => $val ) {
552 if ( in_array( $val, $valid_values, true ) ) {
553 $valid_value[ $key ] = $val;
554 }
555 }
556 return $valid_value;
557 }
558
559 if ( is_string( $value ) ) {
560 $value = sanitize_text_field( $value );
561 }
562
563 if ( count( $valid_values ) === 0 ) {
564 return $value;
565 }
566
567 if ( ! in_array( $value, $valid_values, true ) ) {
568 return $this->get_default( $keys );
569 }
570
571 return $value;
572 }
573
574 /**
575 * Gets the valid values for a given setting path.
576 *
577 * @since 3.14.3
578 * @since 3.17.0 Moved from Base_Endpoint_User_Meta.
579 *
580 * @param array<string> $keys The path to the setting.
581 * @return array<mixed> The valid values for the setting path.
582 */
583 protected function get_valid_values( array $keys ): array {
584 $current = $this->valid_subvalues;
585
586 foreach ( $keys as $key ) {
587 if ( ! is_array( $current ) || ! isset( $current[ $key ] ) ) {
588 return array(); // No valid values for invalid key path.
589 }
590 if ( isset( $current[ $key ]['values'] ) ) {
591 $current = $current[ $key ]['values'];
592 } else {
593 $current = $current[ $key ];
594 }
595 }
596
597 return is_array( $current ) ? $current : array();
598 }
599
600 /**
601 * Gets the default value for a given setting path.
602 *
603 * @since 3.14.3
604 * @since 3.17.0 Moved from Base_Endpoint_User_Meta.
605 *
606 * @param array<string> $keys The path to the setting.
607 * @return mixed|array<mixed>|null The default value for the setting path.
608 */
609 protected function get_default( array $keys ) {
610 $current = $this->default_value;
611
612 foreach ( $keys as $key ) {
613 if ( ! is_array( $current ) || ! isset( $current[ $key ] ) ) {
614 return null; // No default value for invalid key path.
615 }
616 if ( isset( $current[ $key ]['default'] ) ) {
617 $current = $current[ $key ]['default'];
618 } else {
619 $current = $current[ $key ];
620 }
621 }
622
623 return $current; // Return default value for valid key path.
624 }
625
626 /**
627 * Gets the specifications for nested settings based on a composite key.
628 *
629 * @since 3.14.3
630 * @since 3.17.0 Moved from Base_Endpoint_User_Meta.
631 *
632 * @param string $composite_key The composite key representing the nested path.
633 * @return array<mixed> The specifications for the nested path.
634 */
635 protected function get_nested_specs( string $composite_key ): array {
636 $keys = explode( '.', $composite_key );
637 $specs = $this->get_subvalues_specs();
638
639 foreach ( $keys as $key ) {
640 if ( is_array( $specs[ $key ] ) && array_key_exists( 'values', $specs[ $key ] ) ) {
641 $specs = $specs[ $key ]['values'];
642 } else {
643 break;
644 }
645 }
646
647 return $specs;
648 }
649
650 /**
651 * Returns the value held at the passed path.
652 *
653 * @since 3.24.1
654 *
655 * @param array<string, mixed> $settings The settings to look in.
656 * @param array<string> $keys The path to the setting.
657 * @return mixed The value, or null if the path holds none.
658 */
659 protected function get_path_value( array $settings, array $keys ) {
660 $current = $settings;
661
662 foreach ( $keys as $key ) {
663 if ( ! is_array( $current ) || ! array_key_exists( $key, $current ) ) {
664 return null;
665 }
666
667 $current = $current[ $key ];
668 }
669
670 return $current;
671 }
672
673 /**
674 * Removes the value held at the passed path.
675 *
676 * @since 3.24.1
677 *
678 * @param array<string, mixed> $settings The settings to remove from.
679 * @param array<string> $keys The path to the setting.
680 */
681 protected function unset_path_value( array &$settings, array $keys ): void {
682 $last_key = array_pop( $keys );
683
684 if ( null === $last_key ) {
685 return;
686 }
687
688 $current = &$settings;
689
690 foreach ( $keys as $key ) {
691 if ( ! isset( $current[ $key ] ) || ! is_array( $current[ $key ] ) ) {
692 return;
693 }
694
695 $current = &$current[ $key ];
696 }
697
698 unset( $current[ $last_key ] );
699 }
700 }
701