| @@ -48,8 +48,24 @@ | ||
| 48 | 48 | * supported `type` values (bool, int, string, enum, list, url). Each |
| 49 | 49 | * field declares `default` and optional `min` / `max` / `options` / |
| 50 | 50 | * `item_type`. Storage key is `xspeed_module_<slug>`. |
| 51 | 51 | * |
| 52 | + * A field may also declare `constants` -- an ordered list of wp-config.php | |
| 53 | + * constant names that pin its value, most specific first: | |
| 54 | + * | |
| 55 | + * 'redis_host' => array( | |
| 56 | + * 'type' => 'string', | |
| 57 | + * 'default' => '127.0.0.1', | |
| 58 | + * 'constants' => array( 'XSPEED_OC_HOST', 'WP_REDIS_HOST' ), | |
| 59 | + * ), | |
| 60 | + * | |
| 61 | + * Resolution order is then: first DEFINED constant -> stored option -> | |
| 62 | + * `default`. A constant defined as an empty string still wins -- it is an | |
| 63 | + * answer, not an absence. A pinned field is never persisted, and writes | |
| 64 | + * targeting it are refused rather than silently dropped; see | |
| 65 | + * Settings_Manager::origins() / locked_in_input(). This lets a host | |
| 66 | + * (xCloud provisioning Redis) configure a module with no admin visit. (#398) | |
| 67 | + * | |
| 52 | 68 | * @return array<string,array> |
| 53 | 69 | */ |
| 54 | 70 | public function settings_schema(): array { |
| 55 | 71 | return array(); |
| @@ -55,8 +71,41 @@ | ||
| 55 | 71 | return array(); |
| 56 | 72 | } |
| 57 | 73 | |
| 58 | 74 | /** |
| 75 | + * Settings this module keeps at their own defaults when xSpeed cannot own | |
| 76 | + * the page cache, even though they default to ON. | |
| 77 | + * | |
| 78 | + * Settings::conflict_safe_profile() switches off every bool so a | |
| 79 | + * site that already has a caching plugin gets an xSpeed that does nothing | |
| 80 | + * until asked. Two kinds of setting do not belong in that sweep: one where | |
| 81 | + * OFF is the wrong answer (a consent requirement), and one that cannot act | |
| 82 | + * at all while the feature above it is off, where writing false would | |
| 83 | + * suggest a decision nobody made. | |
| 84 | + * | |
| 85 | + * Naming a field here is a decision, not a default: the sweep covers every | |
| 86 | + * bool, so a field is only left alone because someone said so. | |
| 87 | + * | |
| 88 | + * @return string[] Field names from this module's settings_schema(). | |
| 89 | + */ | |
| 90 | + public function conflict_safe_exempt(): array { | |
| 91 | + return array(); | |
| 92 | + } | |
| 93 | + | |
| 94 | + /** | |
| 95 | + * Option keys a module stores OUTSIDE its settings_schema that must | |
| 96 | + * survive a schema-driven save. Settings_Manager rebuilds the option | |
| 97 | + * from the schema on get()/update(), which would otherwise drop these. | |
| 98 | + * Example: the REST-cache module keeps its route `rules` array here so a | |
| 99 | + * plain enabled/ttl save doesn't wipe the rules table. (FBS-82408) | |
| 100 | + * | |
| 101 | + * @return string[] | |
| 102 | + */ | |
| 103 | + public function preserved_keys(): array { | |
| 104 | + return array(); | |
| 105 | + } | |
| 106 | + | |
| 107 | + /** | |
| 59 | 108 | * Schema migrations keyed by target version. Each value is a callable |
| 60 | 109 | * that receives the stored options array and returns the migrated |
| 61 | 110 | * array. Migrations run in version order on first load after upgrade. |
| 62 | 111 | * |
| @@ -99,18 +148,141 @@ | ||
| 99 | 148 | 'methods' => 'POST', |
| 100 | 149 | 'callback' => array( $this, 'rest_update_settings' ), |
| 101 | 150 | 'feature' => static::SLUG, |
| 102 | 151 | ), |
| 152 | + // Take a constant-pinned field back, or hand it to wp-config again. | |
| 153 | + // On every module, because any field can declare `constants`. (#398) | |
| 154 | + array( | |
| 155 | + 'path' => '/override', | |
| 156 | + 'methods' => 'POST', | |
| 157 | + 'callback' => array( $this, 'rest_set_override' ), | |
| 158 | + 'feature' => static::SLUG, | |
| 159 | + ), | |
| 160 | + // Per-field provenance on its own, for a panel that needs to | |
| 161 | + // re-read it after an action that changes which fields are pinned | |
| 162 | + // (enabling the object cache writes constants) without re-fetching | |
| 163 | + // every module descriptor. (#398) | |
| 164 | + array( | |
| 165 | + 'path' => '/origins', | |
| 166 | + 'methods' => 'GET', | |
| 167 | + 'callback' => array( $this, 'rest_get_origins' ), | |
| 168 | + ), | |
| 103 | 169 | ); |
| 104 | 170 | } |
| 105 | 171 | |
| 106 | 172 | /** |
| 173 | + * Where each of this module's settings currently comes from. | |
| 174 | + */ | |
| 175 | + public function rest_get_origins( \WP_REST_Request $request ) { | |
| 176 | + return rest_ensure_response( Settings_Manager::origins( static::SLUG ) ); | |
| 177 | + } | |
| 178 | + | |
| 179 | + /** | |
| 180 | + * Toggle a deliberate override of a constant-pinned field. | |
| 181 | + * | |
| 182 | + * Body: `{ "field": "redis_host", "override": true }`. Overriding does not | |
| 183 | + * itself set a value -- it unlocks the field, and the admin's next save | |
| 184 | + * writes it like any other setting. Reverting hands the field back to the | |
| 185 | + * constant, which resumes winning immediately. | |
| 186 | + */ | |
| 187 | + public function rest_set_override( \WP_REST_Request $request ) { | |
| 188 | + if ( $this->is_license_locked() ) { | |
| 189 | + return new \WP_Error( | |
| 190 | + 'xspeed_license_required', | |
| 191 | + sprintf( | |
| 192 | + /* translators: %s: module slug. */ | |
| 193 | + __( '"%s" is a Pro module and this site has no valid license, so its settings cannot be changed.', 'xspeed' ), | |
| 194 | + static::SLUG | |
| 195 | + ), | |
| 196 | + array( 'status' => 403 ) | |
| 197 | + ); | |
| 198 | + } | |
| 199 | + | |
| 200 | + $body = (array) $request->get_json_params(); | |
| 201 | + $field = isset( $body['field'] ) ? (string) $body['field'] : ''; | |
| 202 | + $on = ! empty( $body['override'] ); | |
| 203 | + | |
| 204 | + $spec = $this->settings_schema()[ $field ] ?? null; | |
| 205 | + if ( ! is_array( $spec ) ) { | |
| 206 | + return new \WP_Error( | |
| 207 | + 'xspeed_unknown_setting', | |
| 208 | + sprintf( | |
| 209 | + /* translators: %s: setting key. */ | |
| 210 | + __( 'Unknown setting: %s', 'xspeed' ), | |
| 211 | + $field | |
| 212 | + ), | |
| 213 | + array( 'status' => 400 ) | |
| 214 | + ); | |
| 215 | + } | |
| 216 | + | |
| 217 | + // Overriding a field no constant pins is meaningless -- it is already | |
| 218 | + // editable -- and would leave a stale entry that silently disables the | |
| 219 | + // lock if a constant appeared later. | |
| 220 | + if ( $on && null === Settings_Manager::constant_source( $spec ) ) { | |
| 221 | + return new \WP_Error( | |
| 222 | + 'xspeed_setting_not_pinned', | |
| 223 | + sprintf( | |
| 224 | + /* translators: %s: setting key. */ | |
| 225 | + __( '"%s" is not defined in wp-config.php, so there is nothing to override.', 'xspeed' ), | |
| 226 | + $field | |
| 227 | + ), | |
| 228 | + array( 'status' => 409 ) | |
| 229 | + ); | |
| 230 | + } | |
| 231 | + | |
| 232 | + if ( $on ) { | |
| 233 | + Settings_Manager::set_override( static::SLUG, $field, true ); | |
| 234 | + } else { | |
| 235 | + /* | |
| 236 | + * The full hand-back, not just dropping the override entry. Once we | |
| 237 | + * have written our own define it outranks the host's, so clearing | |
| 238 | + * the entry alone left the field on our stale value with no route | |
| 239 | + * back -- the panel button did nothing while `wp xspeed objcache | |
| 240 | + * revert`, which did the extra work inline, worked. Both now go | |
| 241 | + * through Settings_Manager::revert(). (#398) | |
| 242 | + */ | |
| 243 | + $reverted = Settings_Manager::revert( static::SLUG, $field ); | |
| 244 | + if ( is_wp_error( $reverted ) ) { | |
| 245 | + return $reverted; | |
| 246 | + } | |
| 247 | + } | |
| 248 | + | |
| 249 | + if ( class_exists( '\\XSpeed\\Activity_Log' ) ) { | |
| 250 | + Activity_Log::record( | |
| 251 | + $on ? 'setting_override_taken' : 'setting_override_reverted', | |
| 252 | + sprintf( | |
| 253 | + $on | |
| 254 | + /* translators: 1: setting key, 2: module slug. */ | |
| 255 | + ? __( 'Overrode "%1$s" on "%2$s" — the wp-config.php value no longer applies.', 'xspeed' ) | |
| 256 | + /* translators: 1: setting key, 2: module slug. */ | |
| 257 | + : __( 'Reverted "%1$s" on "%2$s" to the value set in wp-config.php.', 'xspeed' ), | |
| 258 | + $field, | |
| 259 | + static::SLUG | |
| 260 | + ), | |
| 261 | + Activity_Log::INFO | |
| 262 | + ); | |
| 263 | + } | |
| 264 | + | |
| 265 | + return rest_ensure_response( | |
| 266 | + array( | |
| 267 | + 'ok' => true, | |
| 268 | + 'field' => $field, | |
| 269 | + 'override' => $on, | |
| 270 | + 'settings' => Settings_Manager::get_public( static::SLUG ), | |
| 271 | + 'origins' => Settings_Manager::origins( static::SLUG ), | |
| 272 | + ) | |
| 273 | + ); | |
| 274 | + } | |
| 275 | + | |
| 276 | + /** | |
| 107 | 277 | * Default GET handler — returns all settings (defaults + stored) |
| 108 | - * coerced against the schema. Modules can override but rarely need | |
| 109 | - * to. | |
| 278 | + * coerced against the schema, with secret fields masked. Uses the public | |
| 279 | + * view (not get_settings()) so a credential never leaves in a REST payload; | |
| 280 | + * the engine reads real values through get_settings()/get_setting(). (#115) | |
| 281 | + * Modules can override but rarely need to. | |
| 110 | 282 | */ |
| 111 | 283 | public function rest_get_settings( \WP_REST_Request $request ) { |
| 112 | - return rest_ensure_response( $this->get_settings() ); | |
| 284 | + return rest_ensure_response( Settings_Manager::get_public( static::SLUG ) ); | |
| 113 | 285 | } |
| 114 | 286 | |
| 115 | 287 | /** |
| 116 | 288 | * Default POST handler — validates the JSON body against the |
| @@ -121,12 +293,80 @@ | ||
| 121 | 293 | $params = $request->get_json_params(); |
| 122 | 294 | if ( ! is_array( $params ) ) { |
| 123 | 295 | $params = $request->get_params(); |
| 124 | 296 | } |
| 297 | + | |
| 298 | + // Say so, rather than returning 200 over a write that didn't happen. | |
| 299 | + // update_settings() enforces the gate on every surface; REST is the | |
| 300 | + // one with an error channel, so it reports the reason. (#143) | |
| 301 | + if ( $this->is_license_locked() ) { | |
| 302 | + return new \WP_Error( | |
| 303 | + 'xspeed_license_required', | |
| 304 | + sprintf( | |
| 305 | + /* translators: %s: module slug. */ | |
| 306 | + __( '"%s" is a Pro module and this site has no valid license, so its settings cannot be changed.', 'xspeed' ), | |
| 307 | + static::SLUG | |
| 308 | + ), | |
| 309 | + array( 'status' => 403 ) | |
| 310 | + ); | |
| 311 | + } | |
| 312 | + | |
| 313 | + // A field pinned by a wp-config.php constant cannot be written. Saying | |
| 314 | + // so beats a 200 over a write that silently did nothing -- and naming | |
| 315 | + // the constant tells the caller where to go and change it. (#398) | |
| 316 | + $locked = Settings_Manager::locked_in_input( static::SLUG, is_array( $params ) ? $params : array() ); | |
| 317 | + if ( ! empty( $locked ) ) { | |
| 318 | + $pairs = array(); | |
| 319 | + foreach ( $locked as $field => $constant ) { | |
| 320 | + $pairs[] = $field . ' (' . $constant . ')'; | |
| 321 | + } | |
| 322 | + return new \WP_Error( | |
| 323 | + 'xspeed_setting_defined_in_wp_config', | |
| 324 | + sprintf( | |
| 325 | + /* translators: %s: comma-separated list of "field (CONSTANT_NAME)" pairs. */ | |
| 326 | + __( 'These settings are defined in wp-config.php and cannot be changed here: %s. Edit the constant, or remove it to manage the setting from this screen.', 'xspeed' ), | |
| 327 | + implode( ', ', $pairs ) | |
| 328 | + ), | |
| 329 | + array( | |
| 330 | + 'status' => 409, | |
| 331 | + 'fields' => $locked, | |
| 332 | + ) | |
| 333 | + ); | |
| 334 | + } | |
| 335 | + | |
| 125 | 336 | return rest_ensure_response( $this->update_settings( $params ) ); |
| 126 | 337 | } |
| 127 | 338 | |
| 128 | 339 | /** |
| 340 | + * Is this a Pro module whose settings are locked for want of a licence? | |
| 341 | + * | |
| 342 | + * Shared by the REST handler and the write guard so the two can never | |
| 343 | + * disagree about what is locked. | |
| 344 | + */ | |
| 345 | + final public function is_license_locked(): bool { | |
| 346 | + // The licence module itself must stay writable — otherwise an expired | |
| 347 | + // licence locks the user out of the very screen where a new key is | |
| 348 | + // entered. | |
| 349 | + if ( self::TIER_PRO !== $this->tier() || 'license' === static::SLUG ) { | |
| 350 | + return false; | |
| 351 | + } | |
| 352 | + | |
| 353 | + /** | |
| 354 | + * Filter: xspeed_pro_licensed | |
| 355 | + * | |
| 356 | + * Answered by xspeed-pro — the same filter it already answers when | |
| 357 | + * decorating module descriptors with the `locked` flag, so the write | |
| 358 | + * gate and the UI lock can't disagree. | |
| 359 | + * | |
| 360 | + * Defaults to true so a Free-only install (where nothing hooks this) | |
| 361 | + * is never gated by a question no one is present to answer. | |
| 362 | + * | |
| 363 | + * @param bool $licensed Whether Pro is licensed right now. | |
| 364 | + */ | |
| 365 | + return ! (bool) apply_filters( 'xspeed_pro_licensed', true ); | |
| 366 | + } | |
| 367 | + | |
| 368 | + /** | |
| 129 | 369 | * UI panel declarations consumed by the React dashboard via the |
| 130 | 370 | * bootstrap payload. Each entry: [ |
| 131 | 371 | * 'section' => 'cache' | 'performance' | 'images' | ..., |
| 132 | 372 | * 'position' => int, |
| @@ -175,8 +415,135 @@ | ||
| 175 | 415 | return array(); |
| 176 | 416 | } |
| 177 | 417 | |
| 178 | 418 | /** |
| 419 | + * Is this module actually doing something right now? | |
| 420 | + * | |
| 421 | + * "On" is not one shape across the plugin. Most modules carry an | |
| 422 | + * `enabled` setting, but page caching lives in the GLOBAL option | |
| 423 | + * (`xspeed_options.cache_enabled`), Minify and Lazy are on when any of | |
| 424 | + * their individual flags is set, and MCP is on when it is connected. | |
| 425 | + * The sidebar's "N on" badge counted only the literal `enabled` key, so | |
| 426 | + * it under-reported: on a site with page caching, minification, lazy | |
| 427 | + * loading and MCP all running it read "Cache 2 / Optimization 1" and | |
| 428 | + * left the plugin's headline feature out of its own count. (#363) | |
| 429 | + * | |
| 430 | + * The default below keeps the historic behaviour for the modules that | |
| 431 | + * genuinely do store `enabled`. A module whose "on" means something | |
| 432 | + * else overrides this and answers for itself, which is what stops the | |
| 433 | + * count drifting again the next time a module changes shape. | |
| 434 | + * | |
| 435 | + * Three-state on purpose: | |
| 436 | + * true — on and doing work | |
| 437 | + * false — off | |
| 438 | + * null — no meaningful on/off (a status panel like Health). Callers | |
| 439 | + * must exclude these rather than counting them as off. | |
| 440 | + */ | |
| 441 | + public function is_active(): ?bool { | |
| 442 | + $settings = $this->get_settings(); | |
| 443 | + return array_key_exists( 'enabled', $settings ) | |
| 444 | + ? (bool) $settings['enabled'] | |
| 445 | + : null; | |
| 446 | + } | |
| 447 | + | |
| 448 | + /** | |
| 449 | + * "On if any of my boolean flags is on" — the shape used by modules | |
| 450 | + * that have no master switch, only a set of independent toggles | |
| 451 | + * (Minify, Lazy, Bloat, Gzip). | |
| 452 | + * | |
| 453 | + * Derived from the module's OWN schema rather than a hardcoded key | |
| 454 | + * list, so adding a flag to a module cannot silently fall out of its | |
| 455 | + * active state the way a literal list would. Only `bool` fields count: | |
| 456 | + * an int like `eager_first_n` or a list like `excluded_images` is | |
| 457 | + * configuration for a feature, not evidence the feature is on. | |
| 458 | + * | |
| 459 | + * Returns null when the module declares no boolean flags at all, so a | |
| 460 | + * caller can exclude it rather than record a misleading false. | |
| 461 | + */ | |
| 462 | + final protected function any_bool_flag_on(): ?bool { | |
| 463 | + $schema = $this->settings_schema(); | |
| 464 | + $settings = $this->get_settings(); | |
| 465 | + | |
| 466 | + $found = false; | |
| 467 | + foreach ( $schema as $key => $spec ) { | |
| 468 | + if ( 'bool' !== ( $spec['type'] ?? '' ) ) { | |
| 469 | + continue; | |
| 470 | + } | |
| 471 | + $found = true; | |
| 472 | + if ( ! empty( $settings[ $key ] ) ) { | |
| 473 | + return true; | |
| 474 | + } | |
| 475 | + } | |
| 476 | + | |
| 477 | + return $found ? false : null; | |
| 478 | + } | |
| 479 | + | |
| 480 | + /** | |
| 481 | + * Why is this module reported on or off? One short sentence for the (i) | |
| 482 | + * beside the status pill. | |
| 483 | + * | |
| 484 | + * "On" is not one shape (see is_active()), so without this the pill is a | |
| 485 | + * bare assertion the user cannot check. It is most opaque exactly where | |
| 486 | + * the rule is least obvious: Media Optimization reads "On" while its two | |
| 487 | + * most prominent switches, Lazy-load Images and Iframes, are both off -- | |
| 488 | + * because three other flags are on. The reason names them. | |
| 489 | + * | |
| 490 | + * Computed server-side alongside is_active() so the explanation cannot | |
| 491 | + * drift from the verdict it explains. Returning null means "no reason to | |
| 492 | + * add" and the (i) is not rendered. | |
| 493 | + */ | |
| 494 | + public function active_reason(): ?string { | |
| 495 | + // A module with its own `enabled` switch needs no explaining: the | |
| 496 | + // pill and the switch say the same thing, and an (i) that only | |
| 497 | + // restates the pill is noise on every one of those pages. Silence | |
| 498 | + // here is what keeps the (i) meaningful where it does appear. | |
| 499 | + if ( array_key_exists( 'enabled', $this->get_settings() ) ) { | |
| 500 | + return null; | |
| 501 | + } | |
| 502 | + | |
| 503 | + return $this->bool_flag_reason(); | |
| 504 | + } | |
| 505 | + | |
| 506 | + /** | |
| 507 | + * The reason text for a module whose "on" is "any of my flags is on". | |
| 508 | + * | |
| 509 | + * Names the specific settings that are on, using their schema labels, so | |
| 510 | + * the user can go and look at them rather than take the pill on trust. | |
| 511 | + * Shared by every flag-based module for one consistent sentence. | |
| 512 | + */ | |
| 513 | + final protected function bool_flag_reason(): ?string { | |
| 514 | + $schema = $this->settings_schema(); | |
| 515 | + $settings = $this->get_settings(); | |
| 516 | + | |
| 517 | + $on = array(); | |
| 518 | + foreach ( $schema as $key => $spec ) { | |
| 519 | + if ( 'bool' !== ( $spec['type'] ?? '' ) ) { | |
| 520 | + continue; | |
| 521 | + } | |
| 522 | + if ( ! empty( $settings[ $key ] ) ) { | |
| 523 | + $on[] = $spec['label'] ?? $key; | |
| 524 | + } | |
| 525 | + } | |
| 526 | + | |
| 527 | + // No boolean flags at all means the module has no on/off to explain | |
| 528 | + // (a status panel like Health). Mirrors any_bool_flag_on() returning | |
| 529 | + // null: no verdict, so no reason. | |
| 530 | + if ( null === $this->any_bool_flag_on() ) { | |
| 531 | + return null; | |
| 532 | + } | |
| 533 | + | |
| 534 | + if ( empty( $on ) ) { | |
| 535 | + return __( 'This module has no single on/off switch. It counts as on when any of its settings is on, and none currently is.', 'xspeed' ); | |
| 536 | + } | |
| 537 | + | |
| 538 | + return sprintf( | |
| 539 | + /* translators: %s: comma-separated list of setting labels that are switched on. */ | |
| 540 | + __( 'This module has no single on/off switch. It counts as on because these settings are on: %s.', 'xspeed' ), | |
| 541 | + implode( ', ', $on ) | |
| 542 | + ); | |
| 543 | + } | |
| 544 | + | |
| 545 | + /** | |
| 179 | 546 | * WP-CLI command definitions. Each entry: [ |
| 180 | 547 | * 'name' => 'xspeed cache purge', |
| 181 | 548 | * 'callback' => callable, |
| 182 | 549 | * 'synopsis' => array, // wp-cli synopsis spec |
| @@ -251,14 +618,118 @@ | ||
| 251 | 618 | $opts = Settings_Manager::get( static::SLUG ); |
| 252 | 619 | return array_key_exists( $key, $opts ) ? $opts[ $key ] : $default; |
| 253 | 620 | } |
| 254 | 621 | |
| 622 | + /** | |
| 623 | + * Read one boolean flag WITHOUT building the settings schema. | |
| 624 | + * | |
| 625 | + * `get_setting()` routes through `Settings_Manager::get()`, which calls | |
| 626 | + * `settings_schema()` to know the defaults and types. That schema | |
| 627 | + * declares its `label` / `description` through `__()` — correct, they are | |
| 628 | + * UI copy a translator has to reach. But modules that read their own | |
| 629 | + * settings from `boot()` do so on `plugins_loaded`, before `init`, where | |
| 630 | + * text domains load. Building the schema there translates every label too | |
| 631 | + * early: WordPress 6.7+ emits `_load_textdomain_just_in_time` on each | |
| 632 | + * request, and the labels resolve against a domain that is not loaded | |
| 633 | + * yet, which silently defeats the translation. | |
| 634 | + * | |
| 635 | + * A boot-time gate only ever asks "is this feature switched on", so it | |
| 636 | + * needs the stored value, not the schema. This reads the module's option | |
| 637 | + * directly and casts. `$default` is what applies when the key was never | |
| 638 | + * written — pass the same value the schema declares as that field's | |
| 639 | + * default, or the two disagree on a fresh install. | |
| 640 | + * | |
| 641 | + * Use ONLY for a boot-time on/off check. Anything that needs coercion, | |
| 642 | + * schema defaults, or a non-boolean value must keep using | |
| 643 | + * `get_setting()` / `get_settings()`. | |
| 644 | + * | |
| 645 | + * @param string $key Field name in this module's settings. | |
| 646 | + * @param bool $default Value when the key has never been stored. | |
| 647 | + */ | |
| 648 | + final protected function flag_at_boot( string $key, bool $default = false ): bool { | |
| 649 | + return (bool) $this->setting_at_boot( $key, $default ); | |
| 650 | + } | |
| 651 | + | |
| 652 | + /** | |
| 653 | + * Raw stored value for one setting, WITHOUT building the schema. The | |
| 654 | + * general form of `flag_at_boot()` — see that method for why boot-time | |
| 655 | + * reads must not touch `settings_schema()`. | |
| 656 | + * | |
| 657 | + * No type coercion is applied, so pass a `$default` of the type the | |
| 658 | + * caller expects and cast the result at the call site. Same rule: use | |
| 659 | + * ONLY from code that runs before `init`. | |
| 660 | + * | |
| 661 | + * @param string $key Field name in this module's settings. | |
| 662 | + * @param mixed $default Value when the key has never been stored. | |
| 663 | + * @return mixed | |
| 664 | + */ | |
| 665 | + final protected function setting_at_boot( string $key, $default = null ) { | |
| 666 | + $stored = get_option( Settings_Manager::OPTION_PREFIX . static::SLUG, array() ); | |
| 667 | + if ( ! is_array( $stored ) || ! array_key_exists( $key, $stored ) ) { | |
| 668 | + return $default; | |
| 669 | + } | |
| 670 | + return $stored[ $key ]; | |
| 671 | + } | |
| 672 | + | |
| 255 | 673 | final public function get_settings(): array { |
| 256 | 674 | return Settings_Manager::get( static::SLUG ); |
| 257 | 675 | } |
| 258 | 676 | |
| 259 | 677 | final public function update_settings( array $input ): array { |
| 678 | + $refusal = $this->license_write_refusal( $input ); | |
| 679 | + if ( null !== $refusal ) { | |
| 680 | + return $refusal; | |
| 681 | + } | |
| 260 | 682 | return Settings_Manager::update( static::SLUG, $input ); |
| 683 | + } | |
| 684 | + | |
| 685 | + /** | |
| 686 | + * Enforce the Pro licence gate on writes, or null to allow the write. | |
| 687 | + * | |
| 688 | + * The dashboard renders a Pro module as `locked` without a valid licence | |
| 689 | + * and refuses to toggle it, but that flag is applied by the | |
| 690 | + * `xspeed_module_descriptor` filter — a decoration on the payload the UI | |
| 691 | + * reads. It never reached the write path, so `POST /xspeed/v1/<module>` | |
| 692 | + * with `{"enabled": true}` returned 200 and persisted, and CLI/MCP hit the | |
| 693 | + * same unguarded callbacks. | |
| 694 | + * | |
| 695 | + * That mattered because module availability is decided by | |
| 696 | + * `Tier_Registry::is_available()`, which asks only whether the Pro plugin | |
| 697 | + * is LOADED — never whether it is licensed. So a Pro module boots and runs | |
| 698 | + * its hooks regardless, and a persisted `enabled: true` genuinely turns the | |
| 699 | + * feature on. This was not cosmetic. (#143) | |
| 700 | + * | |
| 701 | + * Free never references Pro: it asks through `xspeed_pro_licensed`, the | |
| 702 | + * same filter Pro already answers for the descriptor. With no Pro plugin | |
| 703 | + * present nothing hooks it, the default `true` stands, and Free modules | |
| 704 | + * are unaffected either way. | |
| 705 | + * | |
| 706 | + * Reads stay open — the dashboard must still be able to GET settings to | |
| 707 | + * render the locked state at all. | |
| 708 | + * | |
| 709 | + * @param array $input Proposed setting values. | |
| 710 | + * @return array|null Current public settings when refused, else null. | |
| 711 | + */ | |
| 712 | + private function license_write_refusal( array $input ): ?array { | |
| 713 | + if ( ! $this->is_license_locked() ) { | |
| 714 | + return null; | |
| 715 | + } | |
| 716 | + | |
| 717 | + Activity_Log::record( | |
| 718 | + 'license_write_refused', | |
| 719 | + sprintf( | |
| 720 | + /* translators: %s: module slug. */ | |
| 721 | + __( 'Refused a settings write to the Pro module "%s" — no valid license.', 'xspeed' ), | |
| 722 | + static::SLUG | |
| 723 | + ), | |
| 724 | + Activity_Log::WARN | |
| 725 | + ); | |
| 726 | + | |
| 727 | + // Return the unchanged public settings rather than throwing: callers | |
| 728 | + // expect the module's settings back, and the dashboard already renders | |
| 729 | + // this module as locked. REST surfaces the refusal explicitly in | |
| 730 | + // rest_update_settings(), which has a WP_Error channel. | |
| 731 | + return Settings_Manager::get_public( static::SLUG ); | |
| 261 | 732 | } |
| 262 | 733 | |
| 263 | 734 | final public function slug(): string { |
| 264 | 735 | return static::SLUG; |