| 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 |
|