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

240 lines 7.0 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 'string':
172 default:
173 return sanitize_text_field( (string) $value );
174 }
175 }
176
177 /**
178 * Validate one field; returns [ coerced_value, was_valid ]. Distinct
179 * from coerce() because validate is strict (out-of-range int is
180 * INVALID) while coerce is forgiving (clamps to range).
181 */
182 private static function validate_field( $value, array $spec ): array {
183 $type = $spec['type'] ?? 'string';
184 switch ( $type ) {
185 case 'bool':
186 return array( (bool) $value, true );
187 case 'int':
188 if ( ! is_numeric( $value ) ) {
189 return array( null, false );
190 }
191 $v = (int) $value;
192 if ( isset( $spec['min'] ) && $v < (int) $spec['min'] ) {
193 return array( null, false );
194 }
195 if ( isset( $spec['max'] ) && $v > (int) $spec['max'] ) {
196 return array( null, false );
197 }
198 return array( $v, true );
199 case 'enum':
200 $ok = in_array( $value, $spec['options'] ?? array(), true );
201 return array( $ok ? $value : null, $ok );
202 case 'list':
203 if ( ! is_array( $value ) ) {
204 return array( null, false );
205 }
206 $item_type = $spec['item_type'] ?? 'string';
207 $out = array();
208 foreach ( $value as $item ) {
209 if ( 'url' === $item_type ) {
210 $u = esc_url_raw( (string) $item );
211 if ( $u ) {
212 $out[] = $u;
213 }
214 } else {
215 $out[] = sanitize_text_field( (string) $item );
216 }
217 }
218 return array( $out, true );
219 case 'url':
220 $u = esc_url_raw( (string) $value );
221 return array( $u, (bool) $u );
222 case 'string':
223 default:
224 return array( sanitize_text_field( (string) $value ), true );
225 }
226 }
227
228 private static function defaults_from_schema( array $schema ): array {
229 $out = array();
230 foreach ( $schema as $key => $spec ) {
231 $out[ $key ] = $spec['default'] ?? null;
232 }
233 return $out;
234 }
235
236 private static function option_key( string $slug ): string {
237 return self::OPTION_PREFIX . $slug;
238 }
239 }
240