PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.1
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.1.1, at includes/class-settings-manager.php

413 lines 13.8 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
111 // Change annotation (issue #45): every real mutation — from the UI,
112 // REST, CLI, or an MCP agent — lands in the activity log with the
113 // old→new diff and its source channel, so the dashboard can tell the
114 // causal story ("expiry raised → hit ratio climbed").
115 self::log_changes( $slug, $current, $clean, array_keys( $schema ) );
116
117 return $clean;
118 }
119
120 /**
121 * Record changed schema fields as one activity event. No-op when
122 * nothing actually changed (idempotent re-saves stay silent).
123 *
124 * @param string $slug Module slug.
125 * @param array $before Settings before the write.
126 * @param array $after Settings after the write.
127 * @param string[] $schema_keys Keys eligible to diff.
128 */
129 private static function log_changes( string $slug, array $before, array $after, array $schema_keys ): void {
130 if ( ! class_exists( '\\XSpeed\\Activity_Log' ) ) {
131 return;
132 }
133 $diffs = array();
134 foreach ( $schema_keys as $key ) {
135 $old = $before[ $key ] ?? null;
136 $new = $after[ $key ] ?? null;
137 if ( $old === $new ) {
138 continue;
139 }
140 if ( self::is_secret_key( $key ) ) {
141 // Never record the value itself — the annotation is served to
142 // the dashboard by the trend endpoints, so a token written
143 // here would be readable from the REST payload.
144 $diffs[] = sprintf( '%s changed', $key );
145 continue;
146 }
147 $diffs[] = sprintf( '%s %s→%s', $key, self::describe_value( $old ), self::describe_value( $new ) );
148 }
149 if ( empty( $diffs ) ) {
150 return;
151 }
152 Activity_Log::record(
153 'settings_changed',
154 sprintf( '%s: %s (via %s)', $slug, implode( ', ', array_slice( $diffs, 0, 5 ) ), self::source_channel() )
155 );
156 }
157
158 /**
159 * Setting keys whose VALUE must never reach the activity log. The log is
160 * surfaced by the dashboard trend endpoints, so anything recorded here is
161 * readable by any user who can load the dashboard.
162 *
163 * Matched on the key name rather than the value, because a credential is
164 * indistinguishable from an ordinary string once it's been stringified.
165 * Pure — unit-tested.
166 *
167 * @param string $key Schema key, e.g. 'api_token'.
168 */
169 public static function is_secret_key( string $key ): bool {
170 return 1 === preg_match( '/(token|password|secret|api_key|passwd|private_key|credential)/i', $key );
171 }
172
173 /** Compact human form of a setting value for the change log. */
174 private static function describe_value( $value ): string {
175 if ( is_bool( $value ) ) {
176 return $value ? 'on' : 'off';
177 }
178 if ( is_array( $value ) ) {
179 return count( $value ) . ' item' . ( 1 === count( $value ) ? '' : 's' );
180 }
181 if ( null === $value ) {
182 return '';
183 }
184 $str = (string) $value;
185 return strlen( $str ) > 40 ? substr( $str, 0, 39 ) . '' : $str;
186 }
187
188 /**
189 * Which surface performed this write. MCP is detected via the tool
190 * dispatcher's in-flight flag; the dashboard UI writes through REST.
191 */
192 private static function source_channel(): string {
193 if ( class_exists( '\\XSpeed\\Modules\\Mcp\\Mcp_Tools' ) && \XSpeed\Modules\Mcp\Mcp_Tools::in_dispatch() ) {
194 return 'mcp';
195 }
196 if ( defined( 'WP_CLI' ) && WP_CLI ) {
197 return 'cli';
198 }
199 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
200 return 'dashboard';
201 }
202 return 'admin';
203 }
204
205 /**
206 * Run any pending schema migrations for a module. Called by
207 * Module_Registry before boot(). Idempotent — migrations only run once
208 * per version bump because we persist `_version` after each successful
209 * migration step.
210 */
211 public static function run_migrations( Module $module ): void {
212 $migrations = $module->migrations();
213 if ( empty( $migrations ) ) {
214 return;
215 }
216 $option_key = self::option_key( $module->slug() );
217 $stored = get_option( $option_key, null );
218 if ( null === $stored ) {
219 return; // fresh install — no data to migrate.
220 }
221 if ( ! is_array( $stored ) ) {
222 $stored = array();
223 }
224 $from = isset( $stored['_version'] ) ? (string) $stored['_version'] : '0.0.0';
225
226 // Sort migrations by version ascending.
227 uksort(
228 $migrations,
229 static function ( $a, $b ) {
230 return version_compare( (string) $a, (string) $b );
231 }
232 );
233
234 $dirty = false;
235 foreach ( $migrations as $target => $callable ) {
236 $target = (string) $target;
237 if ( version_compare( $from, $target, '>=' ) ) {
238 continue;
239 }
240 $migrated = call_user_func( $callable, $stored );
241 if ( is_array( $migrated ) ) {
242 $stored = $migrated;
243 $stored['_version'] = $target;
244 $from = $target;
245 $dirty = true;
246 }
247 }
248
249 if ( $dirty ) {
250 update_option( $option_key, $stored );
251 }
252 }
253
254 /**
255 * Coerce a stored value to the schema's declared type — used on read
256 * to defend against options edited by hand or imported across versions.
257 */
258 private static function coerce( $value, array $spec ) {
259 $type = $spec['type'] ?? 'string';
260 switch ( $type ) {
261 case 'bool':
262 return (bool) $value;
263 case 'int':
264 $v = (int) $value;
265 if ( isset( $spec['min'] ) ) {
266 $v = max( (int) $spec['min'], $v );
267 }
268 if ( isset( $spec['max'] ) ) {
269 $v = min( (int) $spec['max'], $v );
270 }
271 return $v;
272 case 'enum':
273 return in_array( $value, $spec['options'] ?? array(), true )
274 ? $value
275 : ( $spec['default'] ?? null );
276 case 'list':
277 if ( ! is_array( $value ) ) {
278 return $spec['default'] ?? array();
279 }
280 return array_values( array_filter( $value, 'is_scalar' ) );
281 case 'url':
282 $url = esc_url_raw( (string) $value );
283 return $url ?: ( $spec['default'] ?? '' );
284 case 'media':
285 // Media-library image URL. Empty is a valid "no image" state.
286 // esc_url_raw alone lets through any safe URL (…/evil.txt,
287 // non-images) which then renders as a broken <img>; require it
288 // to look like an image and drop anything else to empty.
289 $media = esc_url_raw( (string) $value );
290 return ( '' === $media || self::is_image_url( $media ) ) ? $media : '';
291 case 'string':
292 default:
293 return sanitize_text_field( (string) $value );
294 }
295 }
296
297 /**
298 * Validate one field; returns [ coerced_value, was_valid ]. Distinct
299 * from coerce() because validate is strict (out-of-range int is
300 * INVALID) while coerce is forgiving (clamps to range).
301 */
302 private static function validate_field( $value, array $spec ): array {
303 $type = $spec['type'] ?? 'string';
304 switch ( $type ) {
305 case 'bool':
306 // Strictly validate (don't blindly (bool)-cast). A plain cast
307 // treated every non-empty string as true, so a client sending
308 // the string "false" (or any junk text) silently ENABLED the
309 // toggle. filter_var with FILTER_NULL_ON_FAILURE accepts the
310 // real bool-ish forms (true/false, 1/0, "1"/"0", "true"/
311 // "false", "yes"/"no", "on"/"off") and returns null for
312 // anything else — which we report as invalid so the previous
313 // stored value is kept, mirroring int/enum. (FBS-82158)
314 $b = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
315 if ( null === $b ) {
316 return array( null, false );
317 }
318 return array( $b, true );
319 case 'int':
320 if ( ! is_numeric( $value ) ) {
321 return array( null, false );
322 }
323 $v = (int) $value;
324 if ( isset( $spec['min'] ) && $v < (int) $spec['min'] ) {
325 return array( null, false );
326 }
327 if ( isset( $spec['max'] ) && $v > (int) $spec['max'] ) {
328 return array( null, false );
329 }
330 return array( $v, true );
331 case 'enum':
332 $ok = in_array( $value, $spec['options'] ?? array(), true );
333 return array( $ok ? $value : null, $ok );
334 case 'list':
335 if ( ! is_array( $value ) ) {
336 return array( null, false );
337 }
338 $item_type = $spec['item_type'] ?? 'string';
339 $out = array();
340 foreach ( $value as $item ) {
341 // Skip non-scalar items (e.g. a nested array). Casting one
342 // with (string) emits an "Array to string conversion"
343 // warning and stores the garbage literal "Array" — coerce()
344 // already filters these via is_scalar; mirror it here.
345 // (FBS-82172 Bug 4)
346 if ( ! is_scalar( $item ) ) {
347 continue;
348 }
349 if ( 'url' === $item_type ) {
350 $u = esc_url_raw( (string) $item );
351 if ( $u ) {
352 $out[] = $u;
353 }
354 } else {
355 $out[] = sanitize_text_field( (string) $item );
356 }
357 }
358 return array( $out, true );
359 case 'url':
360 $u = esc_url_raw( (string) $value );
361 return array( $u, (bool) $u );
362 case 'media':
363 // Empty (cleared logo) is valid; any non-empty value must be a
364 // safe URL after esc_url_raw AND look like an image, so a
365 // non-image URL (…/evil.txt) is rejected rather than stored to
366 // render as a broken <img>.
367 $m = esc_url_raw( (string) $value );
368 if ( '' === (string) $value ) {
369 return array( '', true );
370 }
371 $ok = '' !== $m && self::is_image_url( $m );
372 return array( $ok ? $m : '', $ok );
373 case 'string':
374 default:
375 return array( sanitize_text_field( (string) $value ), true );
376 }
377 }
378
379 /**
380 * Whether a URL looks like an image — used to gate `media` fields so a
381 * non-image URL can't be stored and later rendered as a broken <img>
382 * (e.g. the white-label brand logo, FBS-82222). Tests the path extension
383 * against the known image types (query/fragment tolerated). Not a content
384 * check — a cheap, deterministic guard that pairs with the front-end
385 * onError fallback; the Media Library picker already yields conforming
386 * http(s) upload URLs. (data: URIs are stripped by esc_url_raw upstream,
387 * since `data` isn't an allowed protocol, so they never reach here.)
388 */
389 private static function is_image_url( string $url ): bool {
390 $url = trim( $url );
391 if ( '' === $url ) {
392 return false;
393 }
394 // Drop the query string + fragment so ?ver=… / #frag don't defeat the
395 // extension test (e.g. logo.webp?v=2). Plain string ops — no WP URL
396 // parser dependency on this low-level coercion path.
397 $path = (string) preg_replace( '/[?#].*$/', '', $url );
398 return (bool) preg_match( '/\.(jpe?g|png|gif|svg|webp|avif|ico|bmp)$/i', $path );
399 }
400
401 private static function defaults_from_schema( array $schema ): array {
402 $out = array();
403 foreach ( $schema as $key => $spec ) {
404 $out[ $key ] = $spec['default'] ?? null;
405 }
406 return $out;
407 }
408
409 private static function option_key( string $slug ): string {
410 return self::OPTION_PREFIX . $slug;
411 }
412 }
413