PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.8
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.8
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-settings-manager.php

class-settings-manager.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.0.8, at includes/class-settings-manager.php

321 lines 10.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Settings_Manager — per-module typed settings storage, validation, and
4 * versioned migrations.
5 *
6 * Storage layout: one wp_option per module under the key
7 * `xspeed_module_<slug>`. The option value is an associative array that
8 * also carries a `_version` field (the module VERSION at the time of last
9 * write) so migrations know what schema produced the stored data.
10 *
11 * The pre-Module v1 settings (the global cache_enabled / minify_* /
12 * gzip_enabled / cache_expiry / excluded_urls) keep living in
13 * `xspeed_options` under the existing Settings class — Settings_Manager
14 * does not touch them. When v1 features are refactored into Modules,
15 * they'll migrate from `xspeed_options` to their per-module options as
16 * part of that PR.
17 *
18 * @package XSpeed
19 */
20
21 namespace XSpeed;
22
23 defined( 'ABSPATH' ) || exit;
24
25 final class Settings_Manager {
26
27 public const OPTION_PREFIX = 'xspeed_module_';
28
29 /**
30 * Read settings for a module slug. Returns defaults merged with stored
31 * values + the schema applied (unknown keys stripped). Always safe to
32 * call before activation — returns pure defaults if nothing is stored.
33 */
34 public static function get( string $slug ): array {
35 $module = Module_Registry::get( $slug );
36 if ( ! $module ) {
37 return array();
38 }
39 $schema = $module->settings_schema();
40 $defaults = self::defaults_from_schema( $schema );
41 $stored = get_option( self::option_key( $slug ), array() );
42 if ( ! is_array( $stored ) ) {
43 $stored = array();
44 }
45 $merged = array_merge( $defaults, $stored );
46
47 // Strip keys not in schema; coerce types to what the schema declares.
48 $clean = array();
49 foreach ( $schema as $key => $spec ) {
50 $clean[ $key ] = array_key_exists( $key, $merged )
51 ? self::coerce( $merged[ $key ], $spec )
52 : ( $spec['default'] ?? null );
53 }
54
55 // Carry through any out-of-schema keys the module explicitly preserves
56 // (e.g. the REST-cache route `rules` array) so a schema-driven save
57 // doesn't silently drop them. (FBS-82408)
58 foreach ( $module->preserved_keys() as $key ) {
59 if ( array_key_exists( $key, $stored ) ) {
60 $clean[ $key ] = $stored[ $key ];
61 }
62 }
63
64 return $clean;
65 }
66
67 /**
68 * Validate input against the module's schema, merge over stored values,
69 * and persist. Returns the final clean array. Unknown keys are stripped
70 * silently. Out-of-range / wrong-type values fall back to the previous
71 * stored value (or default).
72 */
73 public static function update( string $slug, array $input ): array {
74 $module = Module_Registry::get( $slug );
75 if ( ! $module ) {
76 return array();
77 }
78 $schema = $module->settings_schema();
79 $current = self::get( $slug );
80
81 $clean = $current;
82 foreach ( $schema as $key => $spec ) {
83 if ( ! array_key_exists( $key, $input ) ) {
84 continue;
85 }
86 [ $value, $valid ] = self::validate_field( $input[ $key ], $spec );
87 if ( $valid ) {
88 $clean[ $key ] = $value;
89 }
90 // Invalid → keep $current[$key]. We do not throw; REST layer can
91 // add its own strict-mode validation that 400s on invalid input.
92 }
93
94 // Carry through out-of-schema keys the module explicitly preserves when
95 // they arrive in the INPUT — not only when already stored. Otherwise a
96 // caller that routes through update() to SET a preserved key (e.g. a
97 // migration/profile writing `mobile_separate_review`) has it silently
98 // stripped, because it isn't in $current yet. (FBS-83144)
99 foreach ( $module->preserved_keys() as $key ) {
100 if ( array_key_exists( $key, $input ) ) {
101 $clean[ $key ] = $input[ $key ];
102 }
103 }
104
105 $clean['_version'] = $module->version();
106 update_option( self::option_key( $slug ), $clean );
107
108 // Strip the internal _version key from the returned array.
109 unset( $clean['_version'] );
110 return $clean;
111 }
112
113 /**
114 * Run any pending schema migrations for a module. Called by
115 * Module_Registry before boot(). Idempotent — migrations only run once
116 * per version bump because we persist `_version` after each successful
117 * migration step.
118 */
119 public static function run_migrations( Module $module ): void {
120 $migrations = $module->migrations();
121 if ( empty( $migrations ) ) {
122 return;
123 }
124 $option_key = self::option_key( $module->slug() );
125 $stored = get_option( $option_key, null );
126 if ( null === $stored ) {
127 return; // fresh install — no data to migrate.
128 }
129 if ( ! is_array( $stored ) ) {
130 $stored = array();
131 }
132 $from = isset( $stored['_version'] ) ? (string) $stored['_version'] : '0.0.0';
133
134 // Sort migrations by version ascending.
135 uksort(
136 $migrations,
137 static function ( $a, $b ) {
138 return version_compare( (string) $a, (string) $b );
139 }
140 );
141
142 $dirty = false;
143 foreach ( $migrations as $target => $callable ) {
144 $target = (string) $target;
145 if ( version_compare( $from, $target, '>=' ) ) {
146 continue;
147 }
148 $migrated = call_user_func( $callable, $stored );
149 if ( is_array( $migrated ) ) {
150 $stored = $migrated;
151 $stored['_version'] = $target;
152 $from = $target;
153 $dirty = true;
154 }
155 }
156
157 if ( $dirty ) {
158 update_option( $option_key, $stored );
159 }
160 }
161
162 /**
163 * Coerce a stored value to the schema's declared type — used on read
164 * to defend against options edited by hand or imported across versions.
165 */
166 private static function coerce( $value, array $spec ) {
167 $type = $spec['type'] ?? 'string';
168 switch ( $type ) {
169 case 'bool':
170 return (bool) $value;
171 case 'int':
172 $v = (int) $value;
173 if ( isset( $spec['min'] ) ) {
174 $v = max( (int) $spec['min'], $v );
175 }
176 if ( isset( $spec['max'] ) ) {
177 $v = min( (int) $spec['max'], $v );
178 }
179 return $v;
180 case 'enum':
181 return in_array( $value, $spec['options'] ?? array(), true )
182 ? $value
183 : ( $spec['default'] ?? null );
184 case 'list':
185 if ( ! is_array( $value ) ) {
186 return $spec['default'] ?? array();
187 }
188 return array_values( array_filter( $value, 'is_scalar' ) );
189 case 'url':
190 $url = esc_url_raw( (string) $value );
191 return $url ?: ( $spec['default'] ?? '' );
192 case 'media':
193 // Media-library image URL. Empty is a valid "no image" state.
194 // esc_url_raw alone lets through any safe URL (…/evil.txt,
195 // non-images) which then renders as a broken <img>; require it
196 // to look like an image and drop anything else to empty.
197 $media = esc_url_raw( (string) $value );
198 return ( '' === $media || self::is_image_url( $media ) ) ? $media : '';
199 case 'string':
200 default:
201 return sanitize_text_field( (string) $value );
202 }
203 }
204
205 /**
206 * Validate one field; returns [ coerced_value, was_valid ]. Distinct
207 * from coerce() because validate is strict (out-of-range int is
208 * INVALID) while coerce is forgiving (clamps to range).
209 */
210 private static function validate_field( $value, array $spec ): array {
211 $type = $spec['type'] ?? 'string';
212 switch ( $type ) {
213 case 'bool':
214 // Strictly validate (don't blindly (bool)-cast). A plain cast
215 // treated every non-empty string as true, so a client sending
216 // the string "false" (or any junk text) silently ENABLED the
217 // toggle. filter_var with FILTER_NULL_ON_FAILURE accepts the
218 // real bool-ish forms (true/false, 1/0, "1"/"0", "true"/
219 // "false", "yes"/"no", "on"/"off") and returns null for
220 // anything else — which we report as invalid so the previous
221 // stored value is kept, mirroring int/enum. (FBS-82158)
222 $b = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
223 if ( null === $b ) {
224 return array( null, false );
225 }
226 return array( $b, true );
227 case 'int':
228 if ( ! is_numeric( $value ) ) {
229 return array( null, false );
230 }
231 $v = (int) $value;
232 if ( isset( $spec['min'] ) && $v < (int) $spec['min'] ) {
233 return array( null, false );
234 }
235 if ( isset( $spec['max'] ) && $v > (int) $spec['max'] ) {
236 return array( null, false );
237 }
238 return array( $v, true );
239 case 'enum':
240 $ok = in_array( $value, $spec['options'] ?? array(), true );
241 return array( $ok ? $value : null, $ok );
242 case 'list':
243 if ( ! is_array( $value ) ) {
244 return array( null, false );
245 }
246 $item_type = $spec['item_type'] ?? 'string';
247 $out = array();
248 foreach ( $value as $item ) {
249 // Skip non-scalar items (e.g. a nested array). Casting one
250 // with (string) emits an "Array to string conversion"
251 // warning and stores the garbage literal "Array" — coerce()
252 // already filters these via is_scalar; mirror it here.
253 // (FBS-82172 Bug 4)
254 if ( ! is_scalar( $item ) ) {
255 continue;
256 }
257 if ( 'url' === $item_type ) {
258 $u = esc_url_raw( (string) $item );
259 if ( $u ) {
260 $out[] = $u;
261 }
262 } else {
263 $out[] = sanitize_text_field( (string) $item );
264 }
265 }
266 return array( $out, true );
267 case 'url':
268 $u = esc_url_raw( (string) $value );
269 return array( $u, (bool) $u );
270 case 'media':
271 // Empty (cleared logo) is valid; any non-empty value must be a
272 // safe URL after esc_url_raw AND look like an image, so a
273 // non-image URL (…/evil.txt) is rejected rather than stored to
274 // render as a broken <img>.
275 $m = esc_url_raw( (string) $value );
276 if ( '' === (string) $value ) {
277 return array( '', true );
278 }
279 $ok = '' !== $m && self::is_image_url( $m );
280 return array( $ok ? $m : '', $ok );
281 case 'string':
282 default:
283 return array( sanitize_text_field( (string) $value ), true );
284 }
285 }
286
287 /**
288 * Whether a URL looks like an image — used to gate `media` fields so a
289 * non-image URL can't be stored and later rendered as a broken <img>
290 * (e.g. the white-label brand logo, FBS-82222). Tests the path extension
291 * against the known image types (query/fragment tolerated). Not a content
292 * check — a cheap, deterministic guard that pairs with the front-end
293 * onError fallback; the Media Library picker already yields conforming
294 * http(s) upload URLs. (data: URIs are stripped by esc_url_raw upstream,
295 * since `data` isn't an allowed protocol, so they never reach here.)
296 */
297 private static function is_image_url( string $url ): bool {
298 $url = trim( $url );
299 if ( '' === $url ) {
300 return false;
301 }
302 // Drop the query string + fragment so ?ver=… / #frag don't defeat the
303 // extension test (e.g. logo.webp?v=2). Plain string ops — no WP URL
304 // parser dependency on this low-level coercion path.
305 $path = (string) preg_replace( '/[?#].*$/', '', $url );
306 return (bool) preg_match( '/\.(jpe?g|png|gif|svg|webp|avif|ico|bmp)$/i', $path );
307 }
308
309 private static function defaults_from_schema( array $schema ): array {
310 $out = array();
311 foreach ( $schema as $key => $spec ) {
312 $out[ $key ] = $spec['default'] ?? null;
313 }
314 return $out;
315 }
316
317 private static function option_key( string $slug ): string {
318 return self::OPTION_PREFIX . $slug;
319 }
320 }
321