PluginProbe
Parse.ly / 3.24.2
Parse.ly v3.24.2
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
← All changes | src/rest-api/settings/class-base-settings-endpoint.php +319 -13 3.19.0 → 3.24.2 View file →
@@ -26,8 +26,18 @@
26 26 * @phpstan-type Subvalue_Spec array{values: array<mixed>, default: mixed}
27 27 */
28 28 abstract class Base_Settings_Endpoint extends Base_Endpoint {
29 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 + /**
30 40 * The meta entry's default value. Initialized in the constructor.
31 41 *
32 42 * @since 3.13.0
33 43 * @since 3.17.0 Moved from Base_Endpoint_User_Meta.
@@ -77,8 +87,37 @@
77 87 */
78 88 abstract protected function get_subvalues_specs(): array;
79 89
80 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 + /**
81 120 * Constructor.
82 121 *
83 122 * @since 3.13.0
84 123 * @since 3.17.0 Moved from Base_Endpoint_User_Meta.
@@ -168,21 +207,24 @@
168 207 * API Endpoint: GET settings/{endpoint}/get
169 208 *
170 209 * Retrieves the settings for the current user.
171 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 + *
172 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.
173 219 *
174 220 * @return WP_REST_Response The response object.
175 221 */
176 222 public function get_settings(): WP_REST_Response {
177 - $meta_key = $this->get_meta_key();
178 - $settings = get_user_meta( $this->current_user_id, $meta_key, true );
179 -
180 - if ( ! is_array( $settings ) || 0 === count( $settings ) ) {
181 - $settings = $this->default_value;
182 - }
183 -
184 - return new WP_REST_Response( $settings, 200 );
223 + return new WP_REST_Response(
224 + $this->sanitize_value( $this->get_stored_settings() ),
225 + 200
226 + );
185 227 }
186 228
187 229 /**
188 230 * API Endpoint: PUT settings/{endpoint}/set
@@ -188,9 +230,14 @@
188 230 * API Endpoint: PUT settings/{endpoint}/set
189 231 *
190 232 * Updates the settings for the current user.
191 233 *
234 + * Settings absent from the request keep their current value, rather than
235 + * being reset to their default.
236 + *
192 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.
193 240 *
194 241 * @param WP_REST_Request $request The request object.
195 242 * @return WP_REST_Response|WP_Error The response object.
196 243 */
@@ -204,20 +251,23 @@
204 251 __( 'Settings must be a valid JSON array', 'wp-parsely' )
205 252 );
206 253 }
207 254
208 - $sanitized_value = $this->sanitize_value( $meta_value );
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 + );
209 260
210 261 // If the current settings are the same as the new settings, return early.
211 - $current_settings = $this->get_settings();
212 - if ( $current_settings->get_data() === $sanitized_value ) {
213 - return $current_settings;
262 + if ( $current_settings === $sanitized_value ) {
263 + return new WP_REST_Response( $current_settings, 200 );
214 264 }
215 265
216 266 $update_meta = update_user_meta(
217 267 $this->current_user_id,
218 268 $this->get_meta_key(),
219 - $sanitized_value
269 + $this->strip_inherited_values( $sanitized_value, $stored_settings )
220 270 );
221 271
222 272 if ( false === $update_meta ) {
223 273 return new WP_Error(
@@ -225,12 +275,165 @@
225 275 __( 'Failed to update settings', 'wp-parsely' )
226 276 );
227 277 }
228 278
279 + $this->record_stored_format();
280 +
229 281 return new WP_REST_Response( $sanitized_value, 200 );
230 282 }
231 283
232 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 + /**
233 436 * Returns whether the endpoint is available for access by the current
234 437 * user.
235 438 *
236 439 * @since 3.14.0
@@ -294,8 +497,60 @@
294 497 return $sanitized_value;
295 498 }
296 499
297 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 + /**
298 553 * Sanitizes the passed subvalue.
299 554 *
300 555 * @since 3.13.0
301 556 * @since 3.14.0 Added support for nested arrays.
@@ -409,6 +664,57 @@
409 664 }
410 665 }
411 666
412 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 ] );
413 719 }
414 720 }