PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.4
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.php

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

465 lines 17.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Settings handling.
4 *
5 * @package XSpeed
6 */
7
8 namespace XSpeed;
9
10 defined( 'ABSPATH' ) || exit;
11
12 class Settings {
13
14 const OPTION_KEY = 'xspeed_options';
15
16 /**
17 * Set by a plugin that installs xSpeed on a user's behalf, BEFORE it
18 * activates it.
19 *
20 * A plain option rather than a constant, a filter or a class: the host
21 * writes it at a moment when no xSpeed code has loaded and none can be
22 * relied on to exist, so `update_option( 'xspeed_installed_by', 'my-slug' )`
23 * is the whole contract. It survives into the activation request, which is
24 * where it is read.
25 */
26 const INSTALLED_BY_OPTION = 'xspeed_installed_by';
27
28 /**
29 * Where the slug lives once activation has consumed it.
30 *
31 * The option above is a one-shot TRIGGER, not a record: it changes what
32 * activation does, so leaving it on disk means the next activation is a
33 * host install too. A host whose install fails between writing it and
34 * activating us would otherwise arm a plain user activation to write
35 * advanced-cache.php and edit wp-config.php — for ever. Activation moves
36 * the value here and deletes the trigger, so a stale one is spent by the
37 * first activation that sees it rather than every one after.
38 */
39 const INSTALLER_OPTION = 'xspeed_installer';
40
41 /** Which profile a fresh install came up with. Read back by Host::status(). */
42 const PROFILE_OPTION = 'xspeed_install_profile';
43
44 /** Nothing was decided — the option already existed, so this is not a fresh install. */
45 const PROFILE_NONE = '';
46 /** A clear site the user installed themselves: the Balanced set. */
47 const PROFILE_RECOMMENDED = 'recommended';
48 /** Something else owns the page cache: everything off. */
49 const PROFILE_CONFLICT_SAFE = 'conflict-safe';
50 /** A clear site, installed by another plugin: everything off, page cache on. */
51 const PROFILE_HOST_PAGE_CACHE = 'host-page-cache';
52
53 public static function defaults() {
54 // Migrated out of this legacy blob (now per-module storage):
55 // - minify_html / minify_css / minify_js → xspeed_module_minify
56 // - gzip_enabled → xspeed_module_gzip
57 // - cache_expiry / excluded_urls → xspeed_module_cache
58 // Still here (intentionally, drop-in lifecycle):
59 // - cache_enabled (Cache::toggle owns the .htaccess/wp-config edit)
60 return array(
61 'cache_enabled' => false,
62 );
63 }
64
65 public static function get() {
66 $saved = get_option( self::OPTION_KEY, array() );
67 return wp_parse_args( $saved, self::defaults() );
68 }
69
70 public static function update( array $input ) {
71 $current = self::get();
72 $clean = $current;
73
74 // Every former field is now in per-module storage:
75 // - minify_* → MinifyModule, gzip_enabled → GzipModule,
76 // cache_expiry / excluded_urls → CacheModule.
77 // Only cache_enabled lives on here, owned by Cache::toggle's
78 // drop-in lifecycle. All other writes are silently ignored to
79 // keep duplicate sources from re-forming.
80 if ( isset( $input['cache_enabled'] ) ) {
81 $clean['cache_enabled'] = (bool) $input['cache_enabled'];
82 }
83
84 update_option( self::OPTION_KEY, $clean );
85 return $clean;
86 }
87
88 /**
89 * Decide how a fresh install comes up.
90 *
91 * @return string One of the PROFILE_* constants. PROFILE_NONE means this
92 * was not a fresh install and nothing was decided.
93 */
94 public static function set_defaults(): string {
95 if ( false !== get_option( self::OPTION_KEY ) ) {
96 return self::PROFILE_NONE;
97 }
98
99 add_option( self::OPTION_KEY, self::defaults() );
100
101 /*
102 * Only a genuinely fresh install reaches this branch — the option
103 * survives deactivation, so an upgrade (deactivate → wipe → install →
104 * activate) always finds it present.
105 *
106 * Three ways a fresh install can come up.
107 *
108 * Something else owns the page cache: everything off. The user
109 * installed xSpeed beside a cache plugin they are still using, and
110 * rewriting their markup on activation is not what they asked for.
111 *
112 * Nothing owns it and another plugin installed us: everything off
113 * EXCEPT page caching. That plugin asked the user for site speed and
114 * installed us to provide it, so the cache is the one thing it may
115 * assume — and nothing else, because the user never saw our settings
116 * and did not ask for lazy loading or minification. It is also what
117 * the copy-vendored Setup wrote by hand before every host install, so
118 * this is that behaviour moving to the side that owns the settings.
119 *
120 * Nothing owns it and the user installed us themselves: the Balanced
121 * set, and they choose page caching in the wizard.
122 */
123 $state = self::page_cache_occupant_state();
124 if ( null !== $state ) {
125 $written = self::apply_conflict_safe_profile();
126
127 /**
128 * Fires when a fresh install came up with everything switched off
129 * because something else owns the page cache.
130 *
131 * The host plugin that installed xSpeed uses this to tell the user
132 * what happened; nothing here renders a notice on its behalf.
133 *
134 * @param array $written Module slug => the fields set to false.
135 * @param string $state The detector's ownership state.
136 * @param string $host Whoever claimed the install, or ''.
137 */
138 do_action( 'xspeed_conflict_profile_applied', $written, $state, self::installed_by() );
139 update_option( self::PROFILE_OPTION, self::PROFILE_CONFLICT_SAFE, false );
140 return self::PROFILE_CONFLICT_SAFE;
141 }
142
143 if ( '' !== self::installed_by() ) {
144 // The same sweep the occupied site gets — every Free bool off —
145 // because "the user did not ask for it" is the same fact in both
146 // cases. Only what happens to page caching differs, and that is
147 // Plugin::activate()'s call, not a settings write.
148 self::apply_conflict_safe_profile();
149 update_option( self::PROFILE_OPTION, self::PROFILE_HOST_PAGE_CACHE, false );
150 return self::PROFILE_HOST_PAGE_CACHE;
151 }
152
153 self::seed_recommended_modules();
154 update_option( self::PROFILE_OPTION, self::PROFILE_RECOMMENDED, false );
155 return self::PROFILE_RECOMMENDED;
156 }
157
158 /** Which profile the fresh install came up with, or '' if we never decided. */
159 public static function install_profile(): string {
160 $profile = get_option( self::PROFILE_OPTION, self::PROFILE_NONE );
161 return is_string( $profile ) ? $profile : self::PROFILE_NONE;
162 }
163
164 /**
165 * Who installed xSpeed, or '' when the user did it themselves.
166 *
167 * A slug the host plugin chose — 'essential-addons', 'templately'. Not
168 * validated against a list: this is provenance for a message, never an
169 * authorization, and a host we have never heard of is still allowed to
170 * say who it is.
171 */
172 public static function installed_by(): string {
173 $recorded = get_option( self::INSTALLER_OPTION, '' );
174 if ( is_string( $recorded ) && '' !== $recorded ) {
175 return sanitize_key( $recorded );
176 }
177 $slug = get_option( self::INSTALLED_BY_OPTION, '' );
178 return is_string( $slug ) ? sanitize_key( $slug ) : '';
179 }
180
181 /**
182 * Spend the trigger: record who installed us, and clear the arming option.
183 *
184 * Called once, by Plugin::activate(), after the profile is decided — the
185 * decision reads the trigger, so it cannot be cleared before then.
186 *
187 * @param string $profile The Settings::PROFILE_* this activation chose.
188 */
189 public static function consume_installed_by( string $profile ): void {
190 $slug = get_option( self::INSTALLED_BY_OPTION, '' );
191 $slug = is_string( $slug ) ? sanitize_key( $slug ) : '';
192
193 if ( '' !== $slug && self::PROFILE_NONE !== $profile ) {
194 // A fresh install someone claimed. Worth keeping: it is what
195 // Host::status() reports and what support reads to know whether a
196 // site's settings were chosen by a person.
197 update_option( self::INSTALLER_OPTION, $slug, false );
198 }
199
200 delete_option( self::INSTALLED_BY_OPTION );
201 }
202
203 /**
204 * Does something other than xSpeed own this site's page cache?
205 *
206 * Deliberately NOT `! can_acquire()`. That helper refuses on anything it
207 * cannot verify, which is right for a write that could destroy another
208 * plugin's drop-in — but wrong for choosing a settings profile. An
209 * unreadable wp-config.php would then hand an ordinary site an xSpeed with
210 * every optimisation off and nothing on screen to explain it.
211 *
212 * So this asks the narrower question: is there positive evidence of
213 * somebody else? `unavailable` is not evidence, and neither is a residual
214 * artifact from a plugin that is gone. The write paths keep failing closed
215 * on both.
216 *
217 * `unknown-occupied` counts only when a drop-in is actually there. The
218 * same state also covers `WP_CACHE` left true in wp-config.php with no
219 * drop-in at all — a line a removed cache plugin forgot — and nothing is
220 * serving cached pages then. Treating that as an occupant gave a clean
221 * site an xSpeed with every switch off while the wizard said "No other
222 * caching plugins detected" (PR #295 review). The write path still refuses
223 * it, because it cannot know what set the constant.
224 */
225 private static function page_cache_occupant_state(): ?string {
226 if ( ! class_exists( __NAMESPACE__ . '\\Page_Cache_Detector' ) ) {
227 return null;
228 }
229
230 $report = Page_Cache_Detector::inspect();
231 $state = Page_Cache_Detector::classify( $report )['state'];
232
233 if ( Page_Cache_Detector::STATE_UNKNOWN_OCCUPIED === $state ) {
234 return ! empty( $report['dropin']['exists'] ) ? $state : null;
235 }
236
237 return in_array(
238 $state,
239 array(
240 Page_Cache_Detector::STATE_FOREIGN_LIVE,
241 Page_Cache_Detector::STATE_POSSIBLE_LIVE,
242 Page_Cache_Detector::STATE_CONTESTED,
243 ),
244 true
245 ) ? $state : null;
246 }
247
248 /**
249 * Settings a fresh install starts with, beyond each module's schema
250 * default. Mirrors the wizard's "Balanced" preset — the profile the
251 * product already labels "Recommended for most sites".
252 *
253 * Why this exists: the wizard is skippable, and a WP-CLI or bulk
254 * activation never shows it at all. Those users fell through to the raw
255 * schema defaults, which are more conservative than what we recommend to
256 * the very same site — so whether a site compressed its responses came
257 * down to whether someone clicked through a wizard. Measured on a real
258 * install: gzip off, browser-cache headers off, no minification, while
259 * lazy-load and resource hints (schema default `true`) were on.
260 *
261 * Deliberately excluded: `minify_js`, `defer_js`, `combine_css`,
262 * `combine_js`, `delay_js`. Each can break a theme, and a default that
263 * breaks the site is worse than a default that is merely slow. They stay
264 * opt-in via the wizard's Aggressive preset or the dashboard.
265 *
266 * Public because the portable Setup copy mirrors it: these four are
267 * written by activation rather than declared as schema defaults, so
268 * scanning the schemas alone misses the four settings a user is most
269 * likely to notice.
270 *
271 * @return array<string,array<string,bool>> module slug => settings
272 */
273 public static function recommended_module_settings(): array {
274 return array(
275 // Compression — the single largest byte win, and inert until a
276 // server actually supports it (GzipModule writes .htaccess only
277 // where supports_htaccess() is true, and emits a snippet
278 // otherwise).
279 'gzip' => array( 'gzip_enabled' => true ),
280 // Far-future caching for static assets. Only ever affects
281 // css/js/images/fonts; HTML keeps its own short TTL.
282 'browser-cache' => array( 'enabled' => true ),
283 // HTML + CSS minification. Both are whitespace/comment-level
284 // and do not reorder or combine anything, so they carry none of
285 // the cascade risk that combine_css does.
286 'minify' => array(
287 'minify_html' => true,
288 'minify_css' => true,
289 ),
290 );
291 }
292
293 /**
294 * The settings a site gets when something else owns the page cache.
295 *
296 * Refusing the page cache is only half of "install beside a caching plugin
297 * and do nothing". Two things put the other half back on:
298 *
299 * - recommended_module_settings() above, which activation writes TRUE;
300 * - an ABSENT option row, which is not off. Settings_Manager merges each
301 * module's schema defaults, so `lazy` with no row reads back with four
302 * switches ON, and resource-hints, fonts and preloader likewise.
303 *
304 * So this is composed from the live registry rather than a hand-kept list:
305 * every bool of every registered FREE module, minus what that module names
306 * in Module::conflict_safe_exempt(). Every bool, not only the ones
307 * defaulting on — a default that moves in a later release must not switch
308 * something on behind the user.
309 *
310 * Pro is out of scope: its modules sit behind a licence and their own
311 * enable switches, and writing rows for a plugin that may never be
312 * installed would record decisions on someone else's behalf.
313 *
314 * @return array<string,array<string,bool>> module slug => field => false
315 */
316 public static function conflict_safe_profile(): array {
317 $out = array();
318
319 foreach ( self::registered_modules() as $slug => $module ) {
320 if ( Module::TIER_FREE !== $module::TIER ) {
321 continue;
322 }
323
324 $exempt = $module->conflict_safe_exempt();
325
326 foreach ( $module->settings_schema() as $field => $spec ) {
327 if ( 'bool' !== ( $spec['type'] ?? '' ) || in_array( $field, $exempt, true ) ) {
328 continue;
329 }
330 $out[ $slug ][ $field ] = false;
331 }
332 }
333
334 // The sweep above already covers these as ordinary bools. Naming them
335 // again keeps the two lists in step if the recommended profile ever
336 // grows a field no schema declares.
337 foreach ( self::recommended_module_settings() as $slug => $values ) {
338 foreach ( array_keys( $values ) as $field ) {
339 $out[ $slug ][ $field ] = false;
340 }
341 }
342
343 ksort( $out );
344 foreach ( $out as $slug => $values ) {
345 ksort( $values );
346 $out[ $slug ] = $values;
347 }
348
349 return $out;
350 }
351
352 /**
353 * Write that profile, preserving every key it does not name.
354 *
355 * Merged rather than replaced: option rows outlive plugin deletion, so a
356 * site can still carry lazy-load exclusions or a preloader sitemap from an
357 * earlier install, and there is no reason to destroy those to turn
358 * switches off.
359 *
360 * @return array<string,array<string,bool>> What was written, by slug.
361 */
362 public static function apply_conflict_safe_profile(): array {
363 $written = array();
364
365 foreach ( self::conflict_safe_profile() as $slug => $values ) {
366 $option = 'xspeed_module_' . $slug;
367 $stored = get_option( $option, null );
368 $stored = is_array( $stored ) ? $stored : array();
369 $next = array_merge( $stored, $values );
370
371 if ( $next === $stored ) {
372 continue;
373 }
374
375 update_option( $option, $next, false );
376 $written[ $slug ] = $values;
377 }
378
379 return $written;
380 }
381
382 /**
383 * Every registered module, registering them first when the registry is
384 * empty.
385 *
386 * On the activation request nothing has registered yet, and an empty
387 * registry would compose an empty profile — a silent pass that leaves
388 * every switch on. Firing `xspeed_register_modules` from here was not
389 * enough: init() never ran on that request, so Free's own callback was
390 * not on the action and the firing registered nothing. Plugin owns the
391 * repair — see Plugin::ensure_modules_registered().
392 *
393 * That repair fires the action once per request. Reaching this before
394 * plugins_loaded(20) from anywhere other than activation would register
395 * Free and lock every add-on out for the request; activation is the only
396 * caller, and it must stay that way.
397 *
398 * @return array<string,Module>
399 */
400 private static function registered_modules(): array {
401 $modules = Module_Registry::all();
402 if ( ! empty( $modules ) ) {
403 return $modules;
404 }
405
406 Plugin::instance()->ensure_modules_registered();
407
408 return Module_Registry::all();
409 }
410
411 /**
412 * Write the recommended defaults for a fresh install, without ever
413 * overwriting a value the user has already chosen.
414 *
415 * Each key is written only when it is absent from stored settings, so
416 * this stays safe if it is ever reached on a site that has some — but
417 * not all — module options saved.
418 */
419 private static function seed_recommended_modules(): void {
420 foreach ( self::recommended_module_settings() as $slug => $values ) {
421 $key = 'xspeed_module_' . $slug;
422 $stored = get_option( $key, null );
423 $stored = is_array( $stored ) ? $stored : array();
424
425 $next = $stored;
426 foreach ( $values as $setting => $value ) {
427 if ( array_key_exists( $setting, $stored ) || self::seed_is_refused( $slug, $setting ) ) {
428 continue;
429 }
430 $next[ $setting ] = $value;
431 }
432 if ( $next !== $stored ) {
433 update_option( $key, $next, false );
434 }
435 }
436 }
437
438 /**
439 * Would the dashboard refuse this setting right now?
440 *
441 * A recommended seed is a switch the user never touched, so it must not
442 * turn on what the conflict matrix would refuse to let them turn on: a
443 * fresh install beside Autoptimize seeded HTML and CSS minification while
444 * the same request could already say "Autoptimize is active and handles
445 * the same feature" (PR #295 review). Only settings with a feature key in
446 * the matrix are checked; compression and browser caching have none.
447 */
448 private static function seed_is_refused( string $slug, string $setting ): bool {
449 $keys = array(
450 'minify' => array(
451 'minify_html' => 'minify.html',
452 'minify_css' => 'minify.css',
453 ),
454 );
455 $feature = $keys[ $slug ][ $setting ] ?? null;
456 if ( null === $feature || ! class_exists( __NAMESPACE__ . '\\Conflict_Registry' ) ) {
457 return false;
458 }
459 return Conflict_Registry::STRATEGY_REFUSE === Conflict_Registry::strategy_for( $feature );
460 }
461
462 // sanitize_urls() removed — excluded_urls now owned by CacheModule
463 // and validated by Settings_Manager's typed schema (list / item_type).
464 }
465