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

269 lines 8.3 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 return $clean;
55 }
56
57 /**
58 * Validate input against the module's schema, merge over stored values,
59 * and persist. Returns the final clean array. Unknown keys are stripped
60 * silently. Out-of-range / wrong-type values fall back to the previous
61 * stored value (or default).
62 */
63 public static function update( string $slug, array $input ): array {
64 $module = Module_Registry::get( $slug );
65 if ( ! $module ) {
66 return array();
67 }
68 $schema = $module->settings_schema();
69 $current = self::get( $slug );
70
71 $clean = $current;
72 foreach ( $schema as $key => $spec ) {
73 if ( ! array_key_exists( $key, $input ) ) {
74 continue;
75 }
76 [ $value, $valid ] = self::validate_field( $input[ $key ], $spec );
77 if ( $valid ) {
78 $clean[ $key ] = $value;
79 }
80 // Invalid → keep $current[$key]. We do not throw; REST layer can
81 // add its own strict-mode validation that 400s on invalid input.
82 }
83
84 $clean['_version'] = $module->version();
85 update_option( self::option_key( $slug ), $clean );
86
87 // Strip the internal _version key from the returned array.
88 unset( $clean['_version'] );
89 return $clean;
90 }
91
92 /**
93 * Run any pending schema migrations for a module. Called by
94 * Module_Registry before boot(). Idempotent — migrations only run once
95 * per version bump because we persist `_version` after each successful
96 * migration step.
97 */
98 public static function run_migrations( Module $module ): void {
99 $migrations = $module->migrations();
100 if ( empty( $migrations ) ) {
101 return;
102 }
103 $option_key = self::option_key( $module->slug() );
104 $stored = get_option( $option_key, null );
105 if ( null === $stored ) {
106 return; // fresh install — no data to migrate.
107 }
108 if ( ! is_array( $stored ) ) {
109 $stored = array();
110 }
111 $from = isset( $stored['_version'] ) ? (string) $stored['_version'] : '0.0.0';
112
113 // Sort migrations by version ascending.
114 uksort(
115 $migrations,
116 static function ( $a, $b ) {
117 return version_compare( (string) $a, (string) $b );
118 }
119 );
120
121 $dirty = false;
122 foreach ( $migrations as $target => $callable ) {
123 $target = (string) $target;
124 if ( version_compare( $from, $target, '>=' ) ) {
125 continue;
126 }
127 $migrated = call_user_func( $callable, $stored );
128 if ( is_array( $migrated ) ) {
129 $stored = $migrated;
130 $stored['_version'] = $target;
131 $from = $target;
132 $dirty = true;
133 }
134 }
135
136 if ( $dirty ) {
137 update_option( $option_key, $stored );
138 }
139 }
140
141 /**
142 * Coerce a stored value to the schema's declared type — used on read
143 * to defend against options edited by hand or imported across versions.
144 */
145 private static function coerce( $value, array $spec ) {
146 $type = $spec['type'] ?? 'string';
147 switch ( $type ) {
148 case 'bool':
149 return (bool) $value;
150 case 'int':
151 $v = (int) $value;
152 if ( isset( $spec['min'] ) ) {
153 $v = max( (int) $spec['min'], $v );
154 }
155 if ( isset( $spec['max'] ) ) {
156 $v = min( (int) $spec['max'], $v );
157 }
158 return $v;
159 case 'enum':
160 return in_array( $value, $spec['options'] ?? array(), true )
161 ? $value
162 : ( $spec['default'] ?? null );
163 case 'list':
164 if ( ! is_array( $value ) ) {
165 return $spec['default'] ?? array();
166 }
167 return array_values( array_filter( $value, 'is_scalar' ) );
168 case 'url':
169 $url = esc_url_raw( (string) $value );
170 return $url ?: ( $spec['default'] ?? '' );
171 case 'media':
172 // Media-library URL. Empty is a valid "no image" state — keep
173 // it empty rather than substituting a default.
174 return esc_url_raw( (string) $value );
175 case 'string':
176 default:
177 return sanitize_text_field( (string) $value );
178 }
179 }
180
181 /**
182 * Validate one field; returns [ coerced_value, was_valid ]. Distinct
183 * from coerce() because validate is strict (out-of-range int is
184 * INVALID) while coerce is forgiving (clamps to range).
185 */
186 private static function validate_field( $value, array $spec ): array {
187 $type = $spec['type'] ?? 'string';
188 switch ( $type ) {
189 case 'bool':
190 // Strictly validate (don't blindly (bool)-cast). A plain cast
191 // treated every non-empty string as true, so a client sending
192 // the string "false" (or any junk text) silently ENABLED the
193 // toggle. filter_var with FILTER_NULL_ON_FAILURE accepts the
194 // real bool-ish forms (true/false, 1/0, "1"/"0", "true"/
195 // "false", "yes"/"no", "on"/"off") and returns null for
196 // anything else — which we report as invalid so the previous
197 // stored value is kept, mirroring int/enum. (FBS-82158)
198 $b = filter_var( $value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE );
199 if ( null === $b ) {
200 return array( null, false );
201 }
202 return array( $b, true );
203 case 'int':
204 if ( ! is_numeric( $value ) ) {
205 return array( null, false );
206 }
207 $v = (int) $value;
208 if ( isset( $spec['min'] ) && $v < (int) $spec['min'] ) {
209 return array( null, false );
210 }
211 if ( isset( $spec['max'] ) && $v > (int) $spec['max'] ) {
212 return array( null, false );
213 }
214 return array( $v, true );
215 case 'enum':
216 $ok = in_array( $value, $spec['options'] ?? array(), true );
217 return array( $ok ? $value : null, $ok );
218 case 'list':
219 if ( ! is_array( $value ) ) {
220 return array( null, false );
221 }
222 $item_type = $spec['item_type'] ?? 'string';
223 $out = array();
224 foreach ( $value as $item ) {
225 // Skip non-scalar items (e.g. a nested array). Casting one
226 // with (string) emits an "Array to string conversion"
227 // warning and stores the garbage literal "Array" — coerce()
228 // already filters these via is_scalar; mirror it here.
229 // (FBS-82172 Bug 4)
230 if ( ! is_scalar( $item ) ) {
231 continue;
232 }
233 if ( 'url' === $item_type ) {
234 $u = esc_url_raw( (string) $item );
235 if ( $u ) {
236 $out[] = $u;
237 }
238 } else {
239 $out[] = sanitize_text_field( (string) $item );
240 }
241 }
242 return array( $out, true );
243 case 'url':
244 $u = esc_url_raw( (string) $value );
245 return array( $u, (bool) $u );
246 case 'media':
247 // Empty (cleared logo) is valid; any non-empty value must be a
248 // safe URL after esc_url_raw.
249 $m = esc_url_raw( (string) $value );
250 return array( $m, '' === (string) $value || (bool) $m );
251 case 'string':
252 default:
253 return array( sanitize_text_field( (string) $value ), true );
254 }
255 }
256
257 private static function defaults_from_schema( array $schema ): array {
258 $out = array();
259 foreach ( $schema as $key => $spec ) {
260 $out[ $key ] = $spec['default'] ?? null;
261 }
262 return $out;
263 }
264
265 private static function option_key( string $slug ): string {
266 return self::OPTION_PREFIX . $slug;
267 }
268 }
269