PluginProbe
Ultimate Cursor – Interactive and Animated Custom Cursor and Background Effects Toolkit / trunk
Ultimate Cursor – Interactive and Animated Custom Cursor and Background Effects Toolkit vtrunk
2.4.1 2.4.0 2.3.3 2.3.2 2.3.1 2.3.0 2.2.3 2.2.2 2.2.1 trunk 1.0.0 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.3.5 1.3.6 1.3.7 1.3.8 All 56 releases
ultimate-cursor / classes / class-settings-schema.php

class-settings-schema.php in Ultimate Cursor – Interactive and Animated Custom Cursor and Background Effects Toolkit trunk, at classes/class-settings-schema.php

447 lines 13.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Settings schema — allowlist + per-field sanitization for REST writes.
5 *
6 * Field lists live in the JSON manifests (classes/cursor-field-manifest.json,
7 * classes/background-field-manifest.json) so the allowlist has ONE source of
8 * truth. Unknown keys are DROPPED on write; keys already stored in the DB
9 * survive (writes merge into the stored option), so legacy data is never
10 * destroyed — it just can't be (re)written unless it's in the manifest.
11 *
12 * The full storage pipeline for a REST write is prepare_for_storage():
13 * 1. schema allowlist + typed sanitization (this class)
14 * 2. premium input strip (Ultimate_Cursor_License_Gate::strip_premium_input)
15 * 3. merge into the stored option
16 * 4. data-integrity guard (multiple-mode flag without configs is coerced off)
17 *
18 * Pure logic by design: no WordPress classes, only core sanitization
19 * functions (stubbed in tests/bootstrap.php), so the exact storage path is
20 * unit-testable without a WordPress install.
21 *
22 * @package ultimate-cursor
23 */
24
25 if ( ! defined( 'ABSPATH' ) ) {
26 exit;
27 }
28
29 /**
30 * Class Ultimate_Cursor_Settings_Schema
31 */
32 class Ultimate_Cursor_Settings_Schema {
33
34 /**
35 * Maximum entries accepted in a configurations array.
36 */
37 const MAX_CONFIGS = 100;
38
39 /**
40 * Maximum entries accepted in a list field (colors, emojis, …).
41 */
42 const MAX_LIST_ITEMS = 50;
43
44 /**
45 * Per-request manifest cache, keyed by group.
46 *
47 * A static property (not a `static` local) so tests can flush it.
48 *
49 * @var array
50 */
51 private static $manifests = array();
52
53 /**
54 * Flush the manifest cache (used between unit tests).
55 */
56 public static function reset_cache() {
57 self::$manifests = array();
58 }
59
60 /**
61 * Run the full REST-write storage pipeline.
62 *
63 * @param mixed $input Raw `settings` param from the REST request.
64 * @param array $current Currently stored option value.
65 * @param string $group Settings group: 'cursor' or 'background'.
66 * @return array The value to store.
67 */
68 public static function prepare_for_storage( $input, $current, $group ) {
69 if ( ! is_array( $current ) ) {
70 $current = array();
71 }
72
73 // 1. Allowlist + typed sanitization. Unknown keys are dropped.
74 $clean = self::sanitize_against_schema( $input, self::get_top_level_schema( $group ) );
75
76 // 2. SERVER-SIDE PREMIUM GATE (input only): premium fields are removed
77 // from the incoming payload when there is no valid license, so direct
78 // API calls can't inject them. Premium data already stored in the DB is
79 // intentionally left untouched — it stays dormant and comes back when
80 // the license returns. The output paths (admin localize + frontend
81 // enqueue in class-assets.php) run the full License Gate sanitize, so
82 // dormant values never reach the browser without a license.
83 $clean = Ultimate_Cursor_License_Gate::strip_premium_input( $clean, $group );
84
85 // 3. Merge into the stored option (partial writes are supported).
86 $merged = array_merge( $current, $clean );
87
88 // 4. Data-integrity guard: "multiple" mode without at least one
89 // configuration is a broken state (the frontend would render nothing).
90 // It can arise when the input gate strips the configurations while the
91 // flag survives the merge. Coerce the flag off so the legacy single
92 // top-level config is used instead.
93 $flag_key = ( 'background' === $group ) ? 'enableMultipleBackgrounds' : 'enableMultipleCursors';
94 $configs_key = ( 'background' === $group ) ? 'backgroundConfigurations' : 'cursorConfigurations';
95 if ( ! empty( $merged[ $flag_key ] ) && empty( $merged[ $configs_key ] ) ) {
96 $merged[ $flag_key ] = false;
97 }
98
99 return $merged;
100 }
101
102 /**
103 * Allowlist schema for the option root: config fields (legacy single
104 * format stores them at the top level) plus the structural top-level keys.
105 *
106 * @param string $group Settings group: 'cursor' or 'background'.
107 * @return array field => sanitizer callback.
108 */
109 public static function get_top_level_schema( $group ) {
110 $manifest = self::get_manifest( $group );
111 $schema = self::build_schema( array_merge( $manifest['config'], $manifest['topLevel'] ), $group );
112
113 /**
114 * Filter the top-level settings allowlist for a group.
115 *
116 * Add-ons (the pro plugin) can register extra fields (and their
117 * sanitizer callbacks) here so those fields survive the allowlist.
118 *
119 * @param array $schema field => callable.
120 * @param string $group Settings group.
121 */
122 return apply_filters( "ultimate_cursor_{$group}_settings_schema", $schema, $group );
123 }
124
125 /**
126 * Allowlist schema for one configurations[] entry: config fields plus the
127 * per-entry identity keys.
128 *
129 * @param string $group Settings group: 'cursor' or 'background'.
130 * @return array field => sanitizer callback.
131 */
132 public static function get_config_schema( $group ) {
133 $manifest = self::get_manifest( $group );
134 $schema = self::build_schema( $manifest['config'], $group );
135
136 // Identity keys exist only inside configuration entries — the legacy
137 // single format maps `name` to cursorName/backgroundName at the root.
138 $schema['id'] = array( __CLASS__, 'sanitize_token' );
139 $schema['name'] = 'sanitize_text_field';
140
141 /**
142 * Filter the per-configuration allowlist for a group.
143 *
144 * @param array $schema field => callable.
145 * @param string $group Settings group.
146 */
147 return apply_filters( "ultimate_cursor_{$group}_config_schema", $schema, $group );
148 }
149
150 /**
151 * Apply per-field callbacks; drop keys not in the schema.
152 *
153 * @param mixed $input Raw input (any non-array becomes array()).
154 * @param array $schema field => callback.
155 * @return array
156 */
157 public static function sanitize_against_schema( $input, $schema ) {
158 if ( ! is_array( $input ) ) {
159 return array();
160 }
161
162 $clean = array();
163 foreach ( $schema as $key => $callback ) {
164 if ( ! array_key_exists( $key, $input ) ) {
165 continue;
166 }
167 $clean[ $key ] = call_user_func( $callback, $input[ $key ] );
168 }
169
170 return $clean;
171 }
172
173 /**
174 * Load and cache a group's manifest.
175 *
176 * @param string $group Settings group: 'cursor' or 'background'.
177 * @return array { topLevel: array, config: array }
178 */
179 private static function get_manifest( $group ) {
180 if ( isset( self::$manifests[ $group ] ) ) {
181 return self::$manifests[ $group ];
182 }
183
184 $file = ( 'background' === $group ) ? 'background-field-manifest.json' : 'cursor-field-manifest.json';
185 $path = __DIR__ . '/' . $file;
186 $manifest = array(
187 'topLevel' => array(),
188 'config' => array(),
189 );
190
191 if ( file_exists( $path ) ) {
192 // wp_json_file_decode() was introduced in WP 6.2; fall back for older WP / unit tests.
193 if ( function_exists( 'wp_json_file_decode' ) ) {
194 $decoded = wp_json_file_decode( $path, array( 'associative' => true ) );
195 } else {
196 $decoded = json_decode( file_get_contents( $path ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local plugin file, WP_Filesystem is not warranted.
197 }
198 if ( is_array( $decoded ) ) {
199 foreach ( array( 'topLevel', 'config' ) as $section ) {
200 if ( ! empty( $decoded[ $section ] ) && is_array( $decoded[ $section ] ) ) {
201 $manifest[ $section ] = $decoded[ $section ];
202 }
203 }
204 }
205 }
206
207 self::$manifests[ $group ] = $manifest;
208
209 return $manifest;
210 }
211
212 /**
213 * Map manifest type keywords to sanitizer callbacks.
214 *
215 * @param array $fields field => type keyword.
216 * @param string $group Settings group (needed by the 'configs' type).
217 * @return array field => callable.
218 */
219 private static function build_schema( $fields, $group ) {
220 $schema = array();
221
222 foreach ( $fields as $field => $type ) {
223 switch ( $type ) {
224 case 'text':
225 $schema[ $field ] = 'sanitize_text_field';
226 break;
227 case 'text-long':
228 $schema[ $field ] = array( __CLASS__, 'sanitize_text_long' );
229 break;
230 case 'bool':
231 $schema[ $field ] = 'rest_sanitize_boolean';
232 break;
233 case 'int':
234 $schema[ $field ] = array( __CLASS__, 'sanitize_int' );
235 break;
236 case 'float':
237 $schema[ $field ] = array( __CLASS__, 'sanitize_float' );
238 break;
239 case 'token':
240 $schema[ $field ] = array( __CLASS__, 'sanitize_token' );
241 break;
242 case 'token-null':
243 $schema[ $field ] = array( __CLASS__, 'sanitize_nullable_token' );
244 break;
245 case 'color':
246 $schema[ $field ] = array( __CLASS__, 'sanitize_color' );
247 break;
248 case 'color-list':
249 $schema[ $field ] = array( __CLASS__, 'sanitize_color_list' );
250 break;
251 case 'text-list':
252 $schema[ $field ] = array( __CLASS__, 'sanitize_text_list' );
253 break;
254 case 'url':
255 $schema[ $field ] = 'esc_url_raw';
256 break;
257 case 'configs':
258 $schema[ $field ] = function ( $value ) use ( $group ) {
259 return self::sanitize_configs( $value, $group );
260 };
261 break;
262 // Unknown type keyword: leave the field out of the schema
263 // (fail closed) rather than guessing a sanitizer.
264 }
265 }
266
267 return $schema;
268 }
269
270 /**
271 * Sanitize a configurations[] array: each entry runs through the
272 * per-config schema; non-array entries are dropped.
273 *
274 * @param mixed $value Raw configurations value.
275 * @param string $group Settings group.
276 * @return array
277 */
278 public static function sanitize_configs( $value, $group ) {
279 if ( ! is_array( $value ) ) {
280 return array();
281 }
282
283 $schema = self::get_config_schema( $group );
284 $clean = array();
285 $count = 0;
286
287 foreach ( $value as $entry ) {
288 if ( ! is_array( $entry ) ) {
289 continue;
290 }
291 if ( ++$count > self::MAX_CONFIGS ) {
292 break;
293 }
294 $clean[] = self::sanitize_against_schema( $entry, $schema );
295 }
296
297 return $clean;
298 }
299
300 /*
301 ------------------------------------------------------------------
302 * Field sanitizers
303 * ----------------------------------------------------------------
304 */
305
306 /**
307 * Enum-ish identifier: letters, digits, underscore, dash, dot.
308 * Case is PRESERVED (effect names like 'BubbleCursor' and shape files
309 * like '1.svg' must survive — sanitize_key() would destroy them).
310 *
311 * @param mixed $value Raw value.
312 * @return string
313 */
314 public static function sanitize_token( $value ) {
315 if ( ! is_scalar( $value ) ) {
316 return '';
317 }
318 $value = preg_replace( '/[^A-Za-z0-9_\.\-]/', '', (string) $value );
319 return self::truncate( $value, 64 );
320 }
321
322 /**
323 * Token that accepts null/'' as null (e.g. cursorType when deactivated).
324 *
325 * @param mixed $value Raw value.
326 * @return string|null
327 */
328 public static function sanitize_nullable_token( $value ) {
329 if ( null === $value || '' === $value ) {
330 return null;
331 }
332 return self::sanitize_token( $value );
333 }
334
335 /**
336 * Free-form text capped at 2k chars so an admin can't park multi-MB
337 * payloads in selector / pages-list fields.
338 *
339 * @param mixed $value Raw value.
340 * @return string
341 */
342 public static function sanitize_text_long( $value ) {
343 return self::truncate( sanitize_text_field( (string) $value ), 2000 );
344 }
345
346 /**
347 * Integer cast.
348 *
349 * @param mixed $value Raw value.
350 * @return int
351 */
352 public static function sanitize_int( $value ) {
353 return (int) $value;
354 }
355
356 /**
357 * Float cast.
358 *
359 * @param mixed $value Raw value.
360 * @return float
361 */
362 public static function sanitize_float( $value ) {
363 return (float) $value;
364 }
365
366 /**
367 * Accept hex colors via sanitize_hex_color. For 'transparent' and the
368 * rgba()/named colors the renderers also accept, fall back to a
369 * length-capped sanitized string.
370 *
371 * @param mixed $value Raw value.
372 * @return string
373 */
374 public static function sanitize_color( $value ) {
375 if ( ! is_string( $value ) ) {
376 return '';
377 }
378 $value = trim( $value );
379 if ( '' === $value || 'transparent' === $value ) {
380 return $value;
381 }
382 $hex = sanitize_hex_color( $value );
383 if ( is_string( $hex ) && '' !== $hex ) {
384 return $hex;
385 }
386 return self::truncate( sanitize_text_field( $value ), 64 );
387 }
388
389 /**
390 * List of colors (splash colors, character colors, …).
391 *
392 * @param mixed $value Raw value.
393 * @return array
394 */
395 public static function sanitize_color_list( $value ) {
396 if ( ! is_array( $value ) ) {
397 return array();
398 }
399 $clean = array();
400 foreach ( array_slice( array_values( $value ), 0, self::MAX_LIST_ITEMS ) as $entry ) {
401 $color = self::sanitize_color( $entry );
402 if ( '' !== $color ) {
403 $clean[] = $color;
404 }
405 }
406 return $clean;
407 }
408
409 /**
410 * List of short strings (emoji lists, character/word lists).
411 *
412 * @param mixed $value Raw value.
413 * @return array
414 */
415 public static function sanitize_text_list( $value ) {
416 if ( ! is_array( $value ) ) {
417 return array();
418 }
419 $clean = array();
420 foreach ( array_slice( array_values( $value ), 0, self::MAX_LIST_ITEMS ) as $entry ) {
421 if ( ! is_scalar( $entry ) ) {
422 continue;
423 }
424 $text = self::truncate( sanitize_text_field( (string) $entry ), 64 );
425 if ( '' !== $text ) {
426 $clean[] = $text;
427 }
428 }
429 return $clean;
430 }
431
432 /**
433 * Multibyte-safe truncation with a plain substr fallback for hosts
434 * without the mbstring extension.
435 *
436 * @param string $value Already-sanitized string.
437 * @param int $length Maximum length in characters.
438 * @return string
439 */
440 private static function truncate( $value, $length ) {
441 if ( function_exists( 'mb_substr' ) ) {
442 return mb_substr( $value, 0, $length );
443 }
444 return substr( $value, 0, $length );
445 }
446 }
447