PluginProbe
Parse.ly / 3.24.2
Parse.ly v3.24.2
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 3.20.2 All 106 releases
wp-parsely / src / rest-api / settings / class-base-settings-endpoint.php

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

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