PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.2.1 All 27 releases
xspeed / includes / class-settings.php

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

472 lines 17.6 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 /*
234 * A drop-in file is present either way here. ABANDONED means we may
235 * TAKE it (#391) -- it does not mean the site is a clear field, so a
236 * fresh install still comes up conservative rather than assuming it
237 * has the machine to itself.
238 */
239 if ( Page_Cache_Detector::STATE_UNKNOWN_OCCUPIED === $state
240 || Page_Cache_Detector::STATE_ABANDONED === $state ) {
241 return ! empty( $report['dropin']['exists'] ) ? $state : null;
242 }
243
244 return in_array(
245 $state,
246 array(
247 Page_Cache_Detector::STATE_FOREIGN_LIVE,
248 Page_Cache_Detector::STATE_POSSIBLE_LIVE,
249 Page_Cache_Detector::STATE_CONTESTED,
250 ),
251 true
252 ) ? $state : null;
253 }
254
255 /**
256 * Settings a fresh install starts with, beyond each module's schema
257 * default. Mirrors the wizard's "Balanced" preset — the profile the
258 * product already labels "Recommended for most sites".
259 *
260 * Why this exists: the wizard is skippable, and a WP-CLI or bulk
261 * activation never shows it at all. Those users fell through to the raw
262 * schema defaults, which are more conservative than what we recommend to
263 * the very same site — so whether a site compressed its responses came
264 * down to whether someone clicked through a wizard. Measured on a real
265 * install: gzip off, browser-cache headers off, no minification, while
266 * lazy-load and resource hints (schema default `true`) were on.
267 *
268 * Deliberately excluded: `minify_js`, `defer_js`, `combine_css`,
269 * `combine_js`, `delay_js`. Each can break a theme, and a default that
270 * breaks the site is worse than a default that is merely slow. They stay
271 * opt-in via the wizard's Aggressive preset or the dashboard.
272 *
273 * Public because the portable Setup copy mirrors it: these four are
274 * written by activation rather than declared as schema defaults, so
275 * scanning the schemas alone misses the four settings a user is most
276 * likely to notice.
277 *
278 * @return array<string,array<string,bool>> module slug => settings
279 */
280 public static function recommended_module_settings(): array {
281 return array(
282 // Compression — the single largest byte win, and inert until a
283 // server actually supports it (GzipModule writes .htaccess only
284 // where supports_htaccess() is true, and emits a snippet
285 // otherwise).
286 'gzip' => array( 'gzip_enabled' => true ),
287 // Far-future caching for static assets. Only ever affects
288 // css/js/images/fonts; HTML keeps its own short TTL.
289 'browser-cache' => array( 'enabled' => true ),
290 // HTML + CSS minification. Both are whitespace/comment-level
291 // and do not reorder or combine anything, so they carry none of
292 // the cascade risk that combine_css does.
293 'minify' => array(
294 'minify_html' => true,
295 'minify_css' => true,
296 ),
297 );
298 }
299
300 /**
301 * The settings a site gets when something else owns the page cache.
302 *
303 * Refusing the page cache is only half of "install beside a caching plugin
304 * and do nothing". Two things put the other half back on:
305 *
306 * - recommended_module_settings() above, which activation writes TRUE;
307 * - an ABSENT option row, which is not off. Settings_Manager merges each
308 * module's schema defaults, so `lazy` with no row reads back with four
309 * switches ON, and resource-hints, fonts and preloader likewise.
310 *
311 * So this is composed from the live registry rather than a hand-kept list:
312 * every bool of every registered FREE module, minus what that module names
313 * in Module::conflict_safe_exempt(). Every bool, not only the ones
314 * defaulting on — a default that moves in a later release must not switch
315 * something on behind the user.
316 *
317 * Pro is out of scope: its modules sit behind a licence and their own
318 * enable switches, and writing rows for a plugin that may never be
319 * installed would record decisions on someone else's behalf.
320 *
321 * @return array<string,array<string,bool>> module slug => field => false
322 */
323 public static function conflict_safe_profile(): array {
324 $out = array();
325
326 foreach ( self::registered_modules() as $slug => $module ) {
327 if ( Module::TIER_FREE !== $module::TIER ) {
328 continue;
329 }
330
331 $exempt = $module->conflict_safe_exempt();
332
333 foreach ( $module->settings_schema() as $field => $spec ) {
334 if ( 'bool' !== ( $spec['type'] ?? '' ) || in_array( $field, $exempt, true ) ) {
335 continue;
336 }
337 $out[ $slug ][ $field ] = false;
338 }
339 }
340
341 // The sweep above already covers these as ordinary bools. Naming them
342 // again keeps the two lists in step if the recommended profile ever
343 // grows a field no schema declares.
344 foreach ( self::recommended_module_settings() as $slug => $values ) {
345 foreach ( array_keys( $values ) as $field ) {
346 $out[ $slug ][ $field ] = false;
347 }
348 }
349
350 ksort( $out );
351 foreach ( $out as $slug => $values ) {
352 ksort( $values );
353 $out[ $slug ] = $values;
354 }
355
356 return $out;
357 }
358
359 /**
360 * Write that profile, preserving every key it does not name.
361 *
362 * Merged rather than replaced: option rows outlive plugin deletion, so a
363 * site can still carry lazy-load exclusions or a preloader sitemap from an
364 * earlier install, and there is no reason to destroy those to turn
365 * switches off.
366 *
367 * @return array<string,array<string,bool>> What was written, by slug.
368 */
369 public static function apply_conflict_safe_profile(): array {
370 $written = array();
371
372 foreach ( self::conflict_safe_profile() as $slug => $values ) {
373 $option = 'xspeed_module_' . $slug;
374 $stored = get_option( $option, null );
375 $stored = is_array( $stored ) ? $stored : array();
376 $next = array_merge( $stored, $values );
377
378 if ( $next === $stored ) {
379 continue;
380 }
381
382 update_option( $option, $next, false );
383 $written[ $slug ] = $values;
384 }
385
386 return $written;
387 }
388
389 /**
390 * Every registered module, registering them first when the registry is
391 * empty.
392 *
393 * On the activation request nothing has registered yet, and an empty
394 * registry would compose an empty profile — a silent pass that leaves
395 * every switch on. Firing `xspeed_register_modules` from here was not
396 * enough: init() never ran on that request, so Free's own callback was
397 * not on the action and the firing registered nothing. Plugin owns the
398 * repair — see Plugin::ensure_modules_registered().
399 *
400 * That repair fires the action once per request. Reaching this before
401 * plugins_loaded(20) from anywhere other than activation would register
402 * Free and lock every add-on out for the request; activation is the only
403 * caller, and it must stay that way.
404 *
405 * @return array<string,Module>
406 */
407 private static function registered_modules(): array {
408 $modules = Module_Registry::all();
409 if ( ! empty( $modules ) ) {
410 return $modules;
411 }
412
413 Plugin::instance()->ensure_modules_registered();
414
415 return Module_Registry::all();
416 }
417
418 /**
419 * Write the recommended defaults for a fresh install, without ever
420 * overwriting a value the user has already chosen.
421 *
422 * Each key is written only when it is absent from stored settings, so
423 * this stays safe if it is ever reached on a site that has some — but
424 * not all — module options saved.
425 */
426 private static function seed_recommended_modules(): void {
427 foreach ( self::recommended_module_settings() as $slug => $values ) {
428 $key = 'xspeed_module_' . $slug;
429 $stored = get_option( $key, null );
430 $stored = is_array( $stored ) ? $stored : array();
431
432 $next = $stored;
433 foreach ( $values as $setting => $value ) {
434 if ( array_key_exists( $setting, $stored ) || self::seed_is_refused( $slug, $setting ) ) {
435 continue;
436 }
437 $next[ $setting ] = $value;
438 }
439 if ( $next !== $stored ) {
440 update_option( $key, $next, false );
441 }
442 }
443 }
444
445 /**
446 * Would the dashboard refuse this setting right now?
447 *
448 * A recommended seed is a switch the user never touched, so it must not
449 * turn on what the conflict matrix would refuse to let them turn on: a
450 * fresh install beside Autoptimize seeded HTML and CSS minification while
451 * the same request could already say "Autoptimize is active and handles
452 * the same feature" (PR #295 review). Only settings with a feature key in
453 * the matrix are checked; compression and browser caching have none.
454 */
455 private static function seed_is_refused( string $slug, string $setting ): bool {
456 $keys = array(
457 'minify' => array(
458 'minify_html' => 'minify.html',
459 'minify_css' => 'minify.css',
460 ),
461 );
462 $feature = $keys[ $slug ][ $setting ] ?? null;
463 if ( null === $feature || ! class_exists( __NAMESPACE__ . '\\Conflict_Registry' ) ) {
464 return false;
465 }
466 return Conflict_Registry::STRATEGY_REFUSE === Conflict_Registry::strategy_for( $feature );
467 }
468
469 // sanitize_urls() removed — excluded_urls now owned by CacheModule
470 // and validated by Settings_Manager's typed schema (list / item_type).
471 }
472