PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.2 1.2.3
xspeed / includes / class-settings.php

class-settings.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN trunk, 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