| @@ -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(); |
| @@ -132,12 +148,133 @@ | ||
| 132 | 148 | 'methods' => 'POST', |
| 133 | 149 | 'callback' => array( $this, 'rest_update_settings' ), |
| 134 | 150 | 'feature' => static::SLUG, |
| 135 | 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 | + ), | |
| 136 | 169 | ); |
| 137 | 170 | } |
| 138 | 171 | |
| 139 | 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 | + /** | |
| 140 | 277 | * Default GET handler — returns all settings (defaults + stored) |
| 141 | 278 | * coerced against the schema, with secret fields masked. Uses the public |
| 142 | 279 | * view (not get_settings()) so a credential never leaves in a REST payload; |
| 143 | 280 | * the engine reads real values through get_settings()/get_setting(). (#115) |
| @@ -172,12 +309,79 @@ | ||
| 172 | 309 | array( 'status' => 403 ) |
| 173 | 310 | ); |
| 174 | 311 | } |
| 175 | 312 | |
| 313 | + /* | |
| 314 | + * A module another module has taken over from. | |
| 315 | + * | |
| 316 | + * Distinct from the licence gate above: that one says "you have not | |
| 317 | + * paid for this", and this one says "something else on this site is | |
| 318 | + * already doing it, and running both would be worse than running | |
| 319 | + * either". The Cloudflare module behind the Cloudflare Enterprise | |
| 320 | + * edge is the case it was written for — two proxy layers, each with | |
| 321 | + * its own copy, and purges that reach one of them. | |
| 322 | + * | |
| 323 | + * Only a write that turns the module ON is refused. Turning it off, | |
| 324 | + * or changing anything else, is exactly what somebody resolving the | |
| 325 | + * conflict needs to be able to do. | |
| 326 | + */ | |
| 327 | + $blocked = $this->blocked_by(); | |
| 328 | + if ( null !== $blocked && ! empty( $params['enabled'] ) ) { | |
| 329 | + return new \WP_Error( | |
| 330 | + 'xspeed_module_blocked', | |
| 331 | + $blocked, | |
| 332 | + array( 'status' => 409 ) | |
| 333 | + ); | |
| 334 | + } | |
| 335 | + | |
| 336 | + // A field pinned by a wp-config.php constant cannot be written. Saying | |
| 337 | + // so beats a 200 over a write that silently did nothing -- and naming | |
| 338 | + // the constant tells the caller where to go and change it. (#398) | |
| 339 | + $locked = Settings_Manager::locked_in_input( static::SLUG, is_array( $params ) ? $params : array() ); | |
| 340 | + if ( ! empty( $locked ) ) { | |
| 341 | + $pairs = array(); | |
| 342 | + foreach ( $locked as $field => $constant ) { | |
| 343 | + $pairs[] = $field . ' (' . $constant . ')'; | |
| 344 | + } | |
| 345 | + return new \WP_Error( | |
| 346 | + 'xspeed_setting_defined_in_wp_config', | |
| 347 | + sprintf( | |
| 348 | + /* translators: %s: comma-separated list of "field (CONSTANT_NAME)" pairs. */ | |
| 349 | + __( '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' ), | |
| 350 | + implode( ', ', $pairs ) | |
| 351 | + ), | |
| 352 | + array( | |
| 353 | + 'status' => 409, | |
| 354 | + 'fields' => $locked, | |
| 355 | + ) | |
| 356 | + ); | |
| 357 | + } | |
| 358 | + | |
| 176 | 359 | return rest_ensure_response( $this->update_settings( $params ) ); |
| 177 | 360 | } |
| 178 | 361 | |
| 179 | 362 | /** |
| 363 | + * Why this module may not be switched on right now, or null. | |
| 364 | + * | |
| 365 | + * A seam for one module to stand another down. Free answers null for | |
| 366 | + * everything and never blocks anything itself — it has no opinion about | |
| 367 | + * which modules conflict, and an add-on that knows the answer says so. | |
| 368 | + * | |
| 369 | + * The reason is shown to the customer, so it says what to do rather than | |
| 370 | + * naming an internal state. | |
| 371 | + */ | |
| 372 | + final public function blocked_by(): ?string { | |
| 373 | + /** | |
| 374 | + * Filter: xspeed_module_blocked_by | |
| 375 | + * | |
| 376 | + * @param string|null $reason Why this module cannot be enabled. | |
| 377 | + * @param string $slug The module being asked about. | |
| 378 | + */ | |
| 379 | + $reason = apply_filters( 'xspeed_module_blocked_by', null, static::SLUG ); | |
| 380 | + return is_string( $reason ) && '' !== $reason ? $reason : null; | |
| 381 | + } | |
| 382 | + | |
| 383 | + /** | |
| 180 | 384 | * Is this a Pro module whose settings are locked for want of a licence? |
| 181 | 385 | * |
| 182 | 386 | * Shared by the REST handler and the write guard so the two can never |
| 183 | 387 | * disagree about what is locked. |
| @@ -343,8 +547,20 @@ | ||
| 343 | 547 | return $this->bool_flag_reason(); |
| 344 | 548 | } |
| 345 | 549 | |
| 346 | 550 | /** |
| 551 | + * What the customer still needs before this module can work, when that is | |
| 552 | + * something to buy or activate, in a few words ("Add-on needed"). | |
| 553 | + * | |
| 554 | + * The dashboard shows it in the page header in place of the On/Off pill, | |
| 555 | + * because "Off" on a module nobody can switch on yet reads as a setting | |
| 556 | + * left off. Null, the default, keeps the pill. | |
| 557 | + */ | |
| 558 | + public function status_label(): ?string { | |
| 559 | + return null; | |
| 560 | + } | |
| 561 | + | |
| 562 | + /** | |
| 347 | 563 | * The reason text for a module whose "on" is "any of my flags is on". |
| 348 | 564 | * |
| 349 | 565 | * Names the specific settings that are on, using their schema labels, so |
| 350 | 566 | * the user can go and look at them rather than take the pill on trust. |
| @@ -456,8 +672,59 @@ | ||
| 456 | 672 | */ |
| 457 | 673 | final public function get_setting( string $key, $default = null ) { |
| 458 | 674 | $opts = Settings_Manager::get( static::SLUG ); |
| 459 | 675 | return array_key_exists( $key, $opts ) ? $opts[ $key ] : $default; |
| 676 | + } | |
| 677 | + | |
| 678 | + /** | |
| 679 | + * Read one boolean flag WITHOUT building the settings schema. | |
| 680 | + * | |
| 681 | + * `get_setting()` routes through `Settings_Manager::get()`, which calls | |
| 682 | + * `settings_schema()` to know the defaults and types. That schema | |
| 683 | + * declares its `label` / `description` through `__()` — correct, they are | |
| 684 | + * UI copy a translator has to reach. But modules that read their own | |
| 685 | + * settings from `boot()` do so on `plugins_loaded`, before `init`, where | |
| 686 | + * text domains load. Building the schema there translates every label too | |
| 687 | + * early: WordPress 6.7+ emits `_load_textdomain_just_in_time` on each | |
| 688 | + * request, and the labels resolve against a domain that is not loaded | |
| 689 | + * yet, which silently defeats the translation. | |
| 690 | + * | |
| 691 | + * A boot-time gate only ever asks "is this feature switched on", so it | |
| 692 | + * needs the stored value, not the schema. This reads the module's option | |
| 693 | + * directly and casts. `$default` is what applies when the key was never | |
| 694 | + * written — pass the same value the schema declares as that field's | |
| 695 | + * default, or the two disagree on a fresh install. | |
| 696 | + * | |
| 697 | + * Use ONLY for a boot-time on/off check. Anything that needs coercion, | |
| 698 | + * schema defaults, or a non-boolean value must keep using | |
| 699 | + * `get_setting()` / `get_settings()`. | |
| 700 | + * | |
| 701 | + * @param string $key Field name in this module's settings. | |
| 702 | + * @param bool $default Value when the key has never been stored. | |
| 703 | + */ | |
| 704 | + final protected function flag_at_boot( string $key, bool $default = false ): bool { | |
| 705 | + return (bool) $this->setting_at_boot( $key, $default ); | |
| 706 | + } | |
| 707 | + | |
| 708 | + /** | |
| 709 | + * Raw stored value for one setting, WITHOUT building the schema. The | |
| 710 | + * general form of `flag_at_boot()` — see that method for why boot-time | |
| 711 | + * reads must not touch `settings_schema()`. | |
| 712 | + * | |
| 713 | + * No type coercion is applied, so pass a `$default` of the type the | |
| 714 | + * caller expects and cast the result at the call site. Same rule: use | |
| 715 | + * ONLY from code that runs before `init`. | |
| 716 | + * | |
| 717 | + * @param string $key Field name in this module's settings. | |
| 718 | + * @param mixed $default Value when the key has never been stored. | |
| 719 | + * @return mixed | |
| 720 | + */ | |
| 721 | + final protected function setting_at_boot( string $key, $default = null ) { | |
| 722 | + $stored = get_option( Settings_Manager::OPTION_PREFIX . static::SLUG, array() ); | |
| 723 | + if ( ! is_array( $stored ) || ! array_key_exists( $key, $stored ) ) { | |
| 724 | + return $default; | |
| 725 | + } | |
| 726 | + return $stored[ $key ]; | |
| 460 | 727 | } |
| 461 | 728 | |
| 462 | 729 | final public function get_settings(): array { |
| 463 | 730 | return Settings_Manager::get( static::SLUG ); |