PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.7
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 1.2.0 All 28 releases
xspeed / includes / class-settings-manager.php

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

310 lines 10.1 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 $clean['_version'] = $module->version();
95 update_option( self::option_key( $slug ), $clean );
96
97 // Strip the internal _version key from the returned array.
98 unset( $clean['_version'] );
99 return $clean;
100 }
101
102 /**
103 * Run any pending schema migrations for a module. Called by
104 * Module_Registry before boot(). Idempotent — migrations only run once
105 * per version bump because we persist `_version` after each successful
106 * migration step.
107 */
108 public static function run_migrations( Module $module ): void {
109 $migrations = $module->migrations();
110 if ( empty( $migrations ) ) {
111 return;
112 }
113 $option_key = self::option_key( $module->slug() );
114 $stored = get_option( $option_key, null );
115 if ( null === $stored ) {
116 return; // fresh install — no data to migrate.
117 }
118 if ( ! is_array( $stored ) ) {
119 $stored = array();
120 }
121 $from = isset( $stored['_version'] ) ? (string) $stored['_version'] : '0.0.0';
122
123 // Sort migrations by version ascending.
124 uksort(
125 $migrations,
126 static function ( $a, $b ) {
127 return version_compare( (string) $a, (string) $b );
128 }
129 );
130
131 $dirty = false;
132 foreach ( $migrations as $target => $callable ) {
133 $target = (string) $target;
134 if ( version_compare( $from, $target, '>=' ) ) {
135 continue;
136 }
137 $migrated = call_user_func( $callable, $stored );
138 if ( is_array( $migrated ) ) {
139 $stored = $migrated;
140 $stored['_version'] = $target;
141 $from = $target;
142 $dirty = true;
143 }
144 }
145
146 if ( $dirty ) {
147 update_option( $option_key, $stored );
148 }
149 }
150
151 /**
152 * Coerce a stored value to the schema's declared type — used on read
153 * to defend against options edited by hand or imported across versions.
154 */
155 private static function coerce( $value, array $spec ) {
156 $type = $spec['type'] ?? 'string';
157 switch ( $type ) {
158 case 'bool':
159 return (bool) $value;
160 case 'int':
161 $v = (int) $value;
162 if ( isset( $spec['min'] ) ) {
163 $v = max( (int) $spec['min'], $v );
164 }
165 if ( isset( $spec['max'] ) ) {
166 $v = min( (int) $spec['max'], $v );
167 }
168 return $v;
169 case 'enum':
170 return in_array( $value, $spec['options'] ?? array(), true )
171 ? $value
172 : ( $spec['default'] ?? null );
173 case 'list':
174 if ( ! is_array( $value ) ) {
175 return $spec['default'] ?? array();
176 }
177 return array_values( array_filter( $value, 'is_scalar' ) );
178 case 'url':
179 $url = esc_url_raw( (string) $value );
180 return $url ?: ( $spec['default'] ?? '' );
181 case 'media':
182 // Media-library image URL. Empty is a valid "no image" state.
183 // esc_url_raw alone lets through any safe URL (…/evil.txt,
184 // non-images) which then renders as a broken <img>; require it
185 // to look like an image and drop anything else to empty.
186 $media = esc_url_raw( (string) $value );
187 return ( '' === $media || self::is_image_url( $media ) ) ? $media : '';
188 case 'string':
189 default:
190 return sanitize_text_field( (string) $value );
191 }
192 }
193
194 /**
195 * Validate one field; returns [ coerced_value, was_valid ]. Distinct
196 * from coerce() because validate is strict (out-of-range int is
197 * INVALID) while coerce is forgiving (clamps to range).
198 */
199 private static function validate_field( $value, array $spec ): array {
200 $type = $spec['type'] ?? 'string';
201 switch ( $type ) {
202 case 'bool':
203 // Strictly validate (don't blindly (bool)-cast). A plain cast
204 // treated every non-empty string as true, so a client sending
205 // the string "false" (or any junk text) silently ENABLED the
206 // toggle. filter_var with FILTER_NULL_ON_FAILURE accepts the
207 // real bool-ish forms (true/false, 1/0, "1"/"0", "true"/
208 // "false", "yes"/"no", "on"/"off") and returns null for
209 // anything else — which we report as invalid so the previous
210 // stored value is kept, mirroring int/enum. (FBS-82158)
211 $b = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
212 if ( null === $b ) {
213 return array( null, false );
214 }
215 return array( $b, true );
216 case 'int':
217 if ( ! is_numeric( $value ) ) {
218 return array( null, false );
219 }
220 $v = (int) $value;
221 if ( isset( $spec['min'] ) && $v < (int) $spec['min'] ) {
222 return array( null, false );
223 }
224 if ( isset( $spec['max'] ) && $v > (int) $spec['max'] ) {
225 return array( null, false );
226 }
227 return array( $v, true );
228 case 'enum':
229 $ok = in_array( $value, $spec['options'] ?? array(), true );
230 return array( $ok ? $value : null, $ok );
231 case 'list':
232 if ( ! is_array( $value ) ) {
233 return array( null, false );
234 }
235 $item_type = $spec['item_type'] ?? 'string';
236 $out = array();
237 foreach ( $value as $item ) {
238 // Skip non-scalar items (e.g. a nested array). Casting one
239 // with (string) emits an "Array to string conversion"
240 // warning and stores the garbage literal "Array" — coerce()
241 // already filters these via is_scalar; mirror it here.
242 // (FBS-82172 Bug 4)
243 if ( ! is_scalar( $item ) ) {
244 continue;
245 }
246 if ( 'url' === $item_type ) {
247 $u = esc_url_raw( (string) $item );
248 if ( $u ) {
249 $out[] = $u;
250 }
251 } else {
252 $out[] = sanitize_text_field( (string) $item );
253 }
254 }
255 return array( $out, true );
256 case 'url':
257 $u = esc_url_raw( (string) $value );
258 return array( $u, (bool) $u );
259 case 'media':
260 // Empty (cleared logo) is valid; any non-empty value must be a
261 // safe URL after esc_url_raw AND look like an image, so a
262 // non-image URL (…/evil.txt) is rejected rather than stored to
263 // render as a broken <img>.
264 $m = esc_url_raw( (string) $value );
265 if ( '' === (string) $value ) {
266 return array( '', true );
267 }
268 $ok = '' !== $m && self::is_image_url( $m );
269 return array( $ok ? $m : '', $ok );
270 case 'string':
271 default:
272 return array( sanitize_text_field( (string) $value ), true );
273 }
274 }
275
276 /**
277 * Whether a URL looks like an image — used to gate `media` fields so a
278 * non-image URL can't be stored and later rendered as a broken <img>
279 * (e.g. the white-label brand logo, FBS-82222). Tests the path extension
280 * against the known image types (query/fragment tolerated). Not a content
281 * check — a cheap, deterministic guard that pairs with the front-end
282 * onError fallback; the Media Library picker already yields conforming
283 * http(s) upload URLs. (data: URIs are stripped by esc_url_raw upstream,
284 * since `data` isn't an allowed protocol, so they never reach here.)
285 */
286 private static function is_image_url( string $url ): bool {
287 $url = trim( $url );
288 if ( '' === $url ) {
289 return false;
290 }
291 // Drop the query string + fragment so ?ver=… / #frag don't defeat the
292 // extension test (e.g. logo.webp?v=2). Plain string ops — no WP URL
293 // parser dependency on this low-level coercion path.
294 $path = (string) preg_replace( '/[?#].*$/', '', $url );
295 return (bool) preg_match( '/\.(jpe?g|png|gif|svg|webp|avif|ico|bmp)$/i', $path );
296 }
297
298 private static function defaults_from_schema( array $schema ): array {
299 $out = array();
300 foreach ( $schema as $key => $spec ) {
301 $out[ $key ] = $spec['default'] ?? null;
302 }
303 return $out;
304 }
305
306 private static function option_key( string $slug ): string {
307 return self::OPTION_PREFIX . $slug;
308 }
309 }
310