| @@ -26,8 +26,23 @@ | ||
| 26 | 26 | |
| 27 | 27 | public const OPTION_PREFIX = 'xspeed_module_'; |
| 28 | 28 | |
| 29 | 29 | /** |
| 30 | + * Marker prefixing a secret value that has been encrypted at rest. A stored | |
| 31 | + * value without this prefix is legacy plaintext (or empty) and is read back | |
| 32 | + * verbatim — so the encryption rollout is lazy and non-destructive. | |
| 33 | + */ | |
| 34 | + private const SECRET_CIPHER_PREFIX = 'xsenc:v1:'; | |
| 35 | + | |
| 36 | + /** | |
| 37 | + * Bullet run embedded in a masked secret hint. Also the write-preserve | |
| 38 | + * sentinel: an incoming value containing it (or an empty string) is treated | |
| 39 | + * as "the client is echoing the mask, keep the stored secret" — so saving an | |
| 40 | + * unrelated field on the same panel never wipes the credential. (#115) | |
| 41 | + */ | |
| 42 | + public const SECRET_MASK_BULLETS = '••••'; | |
| 43 | + | |
| 44 | + /** | |
| 30 | 45 | * Read settings for a module slug. Returns defaults merged with stored |
| 31 | 46 | * values + the schema applied (unknown keys stripped). Always safe to |
| 32 | 47 | * call before activation — returns pure defaults if nothing is stored. |
| 33 | 48 | */ |
| @@ -46,8 +61,32 @@ | ||
| 46 | 61 | |
| 47 | 62 | // Strip keys not in schema; coerce types to what the schema declares. |
| 48 | 63 | $clean = array(); |
| 49 | 64 | foreach ( $schema as $key => $spec ) { |
| 65 | + // A field whose value is pinned by a wp-config.php constant reads | |
| 66 | + // back from that constant, whatever the option row says. This is | |
| 67 | + // the single resolution point every consumer -- module, REST, CLI, | |
| 68 | + // MCP, and Pro via its own Settings_Manager::get() calls -- passes | |
| 69 | + // through, so status, test and enable can never disagree with what | |
| 70 | + // the drop-in actually connected to. (#398) | |
| 71 | + $constant = self::effective_constant( $slug, $key, $spec ); | |
| 72 | + if ( null !== $constant ) { | |
| 73 | + $clean[ $key ] = self::coerce( self::constant_value( $key, $constant, $spec ), $spec ); | |
| 74 | + continue; | |
| 75 | + } | |
| 76 | + /* | |
| 77 | + * A module whose runtime reads its config before WordPress loads | |
| 78 | + * may keep it OUTSIDE the option row -- the object cache writes a | |
| 79 | + * sidecar when wp-config.php is read-only. Ask for that value | |
| 80 | + * before falling back to the row, or the panel reports the stored | |
| 81 | + * settings while the drop-in runs on the sidecar's. (#398) | |
| 82 | + */ | |
| 83 | + $external = apply_filters( 'xspeed_setting_external_source', null, $slug, $key, $spec ); | |
| 84 | + if ( null !== $external ) { | |
| 85 | + $clean[ $key ] = self::coerce( $external, $spec ); | |
| 86 | + continue; | |
| 87 | + } | |
| 88 | + | |
| 50 | 89 | $clean[ $key ] = array_key_exists( $key, $merged ) |
| 51 | 90 | ? self::coerce( $merged[ $key ], $spec ) |
| 52 | 91 | : ( $spec['default'] ?? null ); |
| 53 | 92 | } |
| @@ -64,8 +103,722 @@ | ||
| 64 | 103 | return $clean; |
| 65 | 104 | } |
| 66 | 105 | |
| 67 | 106 | /** |
| 107 | + * The constant that pins a field's value, or null when none is defined. | |
| 108 | + * | |
| 109 | + * A field opts in by declaring `constants` in its schema spec -- an ordered | |
| 110 | + * list of constant names, most specific first (our own `XSPEED_*` before a | |
| 111 | + * community convention like `WP_REDIS_*`). The first DEFINED name wins. | |
| 112 | + * | |
| 113 | + * `defined()` is the whole test, deliberately: a constant defined as an | |
| 114 | + * empty string is an answer ("this server has no auth"), not an absence. | |
| 115 | + * Falling through to the DB there would silently re-introduce a credential | |
| 116 | + * the operator removed on purpose. (#398) | |
| 117 | + * | |
| 118 | + * @param array $spec Field spec from settings_schema(). | |
| 119 | + * @return string|null Winning constant name, or null. | |
| 120 | + */ | |
| 121 | + public static function constant_source( array $spec ): ?string { | |
| 122 | + // A field can also be pinned by a wp-config.php GLOBAL rather than a | |
| 123 | + // constant -- $memcached_servers is the de-facto standard for Memcached | |
| 124 | + // the way WP_REDIS_* is for Redis, and hosts write it. It is checked | |
| 125 | + // first because a site that sets it means it; `global_source` names the | |
| 126 | + // variable and the element to read. (#398) | |
| 127 | + $global = self::global_source( $spec ); | |
| 128 | + if ( null !== $global ) { | |
| 129 | + return $global; | |
| 130 | + } | |
| 131 | + | |
| 132 | + $pair_only = (array) ( $spec['constants_pair_only'] ?? array() ); | |
| 133 | + foreach ( (array) ( $spec['constants'] ?? array() ) as $name ) { | |
| 134 | + if ( ! is_string( $name ) || '' === $name || ! defined( $name ) ) { | |
| 135 | + continue; | |
| 136 | + } | |
| 137 | + // Named in `constants_pair_only`, this constant answers for the | |
| 138 | + // field ONLY in its array form, where it carries both halves of a | |
| 139 | + // credential. A username field reading a plain-string | |
| 140 | + // WP_REDIS_PASSWORD would otherwise authenticate with the password | |
| 141 | + // as the username. | |
| 142 | + if ( in_array( $name, $pair_only, true ) && ! is_array( constant( $name ) ) ) { | |
| 143 | + continue; | |
| 144 | + } | |
| 145 | + /* | |
| 146 | + * A legacy name kept only for backward compatibility, valid while | |
| 147 | + * another constant holds a particular value. XSPEED_OC_PORT used to | |
| 148 | + * serve BOTH backends; Memcached fields still read it so an install | |
| 149 | + * configured before the split keeps working -- but only when | |
| 150 | + * Memcached is actually the backend, or a Redis site would show the | |
| 151 | + * Redis port in its Memcached field. (#398) | |
| 152 | + */ | |
| 153 | + $when = (array) ( $spec['constants_when'] ?? array() ); | |
| 154 | + if ( isset( $when[ $name ] ) ) { | |
| 155 | + $gate = $when[ $name ]; | |
| 156 | + $on = $gate['constant'] ?? ''; | |
| 157 | + /* | |
| 158 | + * Resolved through the same filter the rest of the module uses, | |
| 159 | + * not `defined()` alone: on a host where wp-config.php is | |
| 160 | + * read-only the backend lives in the sidecar and no constant is | |
| 161 | + * defined at all. Reading only the constant there rejected the | |
| 162 | + * legacy name, so the panel showed the default host while the | |
| 163 | + * drop-in -- which does consult the sidecar -- used the real | |
| 164 | + * one. Panel and runtime disagreeing is the bug this change | |
| 165 | + * exists to remove. (#398) | |
| 166 | + */ | |
| 167 | + $actual = defined( $on ) ? constant( $on ) : null; | |
| 168 | + | |
| 169 | + /** | |
| 170 | + * Filter: xspeed_constant_gate_value | |
| 171 | + * | |
| 172 | + * @param mixed $actual Value of the gating constant, or null. | |
| 173 | + * @param string $on Gating constant name. | |
| 174 | + */ | |
| 175 | + $actual = apply_filters( 'xspeed_constant_gate_value', $actual, $on ); | |
| 176 | + if ( $actual !== ( $gate['is'] ?? null ) ) { | |
| 177 | + continue; | |
| 178 | + } | |
| 179 | + } | |
| 180 | + return $name; | |
| 181 | + } | |
| 182 | + return null; | |
| 183 | + } | |
| 184 | + | |
| 185 | + /** | |
| 186 | + * The wp-config.php GLOBAL pinning this field, as a display name, or null. | |
| 187 | + * | |
| 188 | + * Declared as `global_source => [ 'var' => 'memcached_servers', 'path' => | |
| 189 | + * [ 0, 0 ] ]` — the variable name plus the path to the element this field | |
| 190 | + * reads. Returned in `$var` form ("$memcached_servers") because that is | |
| 191 | + * what the panel shows and what the reader types into a search box. | |
| 192 | + * | |
| 193 | + * Memcached has no constant convention the way Redis has WP_REDIS_*; the | |
| 194 | + * global IS the convention, used by W3TC and the Memcached Object Cache | |
| 195 | + * drop-in alike, and hosts write it. Without this the drop-in honoured it | |
| 196 | + * while the panel did not -- the same two-truths bug this change closes | |
| 197 | + * for Redis. (#398) | |
| 198 | + */ | |
| 199 | + public static function global_source( array $spec ): ?string { | |
| 200 | + $decl = $spec['global_source'] ?? null; | |
| 201 | + if ( ! is_array( $decl ) || empty( $decl['var'] ) ) { | |
| 202 | + return null; | |
| 203 | + } | |
| 204 | + return null === self::global_value( $spec ) ? null : '$' . (string) $decl['var']; | |
| 205 | + } | |
| 206 | + | |
| 207 | + /** | |
| 208 | + * The value a `global_source` field resolves to, or null when the global is | |
| 209 | + * absent or does not carry the declared path. | |
| 210 | + * | |
| 211 | + * @return mixed|null | |
| 212 | + */ | |
| 213 | + private static function global_value( array $spec ) { | |
| 214 | + $decl = $spec['global_source'] ?? null; | |
| 215 | + if ( ! is_array( $decl ) || empty( $decl['var'] ) ) { | |
| 216 | + return null; | |
| 217 | + } | |
| 218 | + $var = (string) $decl['var']; | |
| 219 | + if ( ! array_key_exists( $var, $GLOBALS ) ) { | |
| 220 | + return null; | |
| 221 | + } | |
| 222 | + $value = $GLOBALS[ $var ]; | |
| 223 | + | |
| 224 | + /* | |
| 225 | + * A `reader` names a function that knows the global's real shape, for a | |
| 226 | + * convention a fixed path cannot express. $memcached_servers ships in | |
| 227 | + * two forms -- a [host, port] pair and a "host:port" string under a | |
| 228 | + * `default` key -- and walking `[0][0]` reads the second as the whole | |
| 229 | + * string, or misses it entirely. The drop-in has to answer identically, | |
| 230 | + * so both call the same function rather than each carrying a walker. | |
| 231 | + * (#398) | |
| 232 | + */ | |
| 233 | + $reader = $decl['reader'] ?? null; | |
| 234 | + if ( null !== $reader ) { | |
| 235 | + if ( ! is_callable( $reader ) ) { | |
| 236 | + return null; | |
| 237 | + } | |
| 238 | + $slot = (int) ( $decl['slot'] ?? 0 ); | |
| 239 | + $pair = $reader( $value ); | |
| 240 | + return is_array( $pair ) && isset( $pair[ $slot ] ) ? $pair[ $slot ] : null; | |
| 241 | + } | |
| 242 | + | |
| 243 | + foreach ( (array) ( $decl['path'] ?? array() ) as $step ) { | |
| 244 | + if ( ! is_array( $value ) || ! array_key_exists( $step, $value ) ) { | |
| 245 | + return null; | |
| 246 | + } | |
| 247 | + $value = $value[ $step ]; | |
| 248 | + } | |
| 249 | + // An array here means the path did not reach a scalar -- a malformed | |
| 250 | + // $memcached_servers, say. Treat it as absent rather than coercing it | |
| 251 | + // to the string "Array" the way the Redis password bug once did. | |
| 252 | + return is_array( $value ) ? null : $value; | |
| 253 | + } | |
| 254 | + | |
| 255 | + /** | |
| 256 | + * The constant that EFFECTIVELY pins a field, or null. | |
| 257 | + * | |
| 258 | + * `constant_source()` answers "is a constant defined for this field"; | |
| 259 | + * this answers "does that constant still win", which is the question every | |
| 260 | + * caller actually has. They differ for one reason: an admin can | |
| 261 | + * deliberately override a pinned field (DESIGN.md §24.36), after which the | |
| 262 | + * constant is shadowed by our own and the field behaves normally again. | |
| 263 | + * | |
| 264 | + * Read/lock/refuse paths all go through here, so an override cannot be | |
| 265 | + * honoured in one place and ignored in another. (#398) | |
| 266 | + * | |
| 267 | + * @param string $slug Module slug. | |
| 268 | + * @param string $key Field key. | |
| 269 | + * @param array $spec Field spec from settings_schema(). | |
| 270 | + */ | |
| 271 | + public static function effective_constant( string $slug, string $key, array $spec ): ?string { | |
| 272 | + if ( self::is_overridden( $slug, $key ) ) { | |
| 273 | + return null; | |
| 274 | + } | |
| 275 | + | |
| 276 | + /* | |
| 277 | + * A HOST define outranks one we wrote ourselves, whatever order the | |
| 278 | + * schema lists them in. Ours is not a lock -- it is only where the | |
| 279 | + * drop-in can read the option row -- so letting it win meant a host | |
| 280 | + * that added or ROTATED a define after we had written ours was ignored | |
| 281 | + * for good, with nothing on screen to say so. The site kept using our | |
| 282 | + * stale snapshot until someone happened to save in the panel. | |
| 283 | + * | |
| 284 | + * Deliberate overrides are unaffected: is_overridden() has already | |
| 285 | + * returned above, which is the one case where an admin has asked for | |
| 286 | + * our value to win. (#398) | |
| 287 | + */ | |
| 288 | + $constant = self::constant_source( $spec ); | |
| 289 | + if ( null !== $constant && in_array( $constant, self::self_written_constants(), true ) ) { | |
| 290 | + $foreign = self::foreign_constant( $slug, $key ); | |
| 291 | + if ( null !== $foreign ) { | |
| 292 | + return $foreign; | |
| 293 | + } | |
| 294 | + } | |
| 295 | + return $constant; | |
| 296 | + } | |
| 297 | + | |
| 298 | + /** | |
| 299 | + * Option key holding the fields an admin deliberately took back from | |
| 300 | + * wp-config.php. Kept OUTSIDE the module's settings row so a schema-driven | |
| 301 | + * save can never drop it, and so it survives a module version migration. | |
| 302 | + */ | |
| 303 | + private const OVERRIDE_OPTION = 'xspeed_overridden_constants'; | |
| 304 | + | |
| 305 | + /** | |
| 306 | + * True while update() is settling overrides at the end of a save. | |
| 307 | + * | |
| 308 | + * A save PROMOTES an overridden field: the typed value is written into the | |
| 309 | + * module's own constants and the override then lifts. That lift fires | |
| 310 | + * `xspeed_setting_override_changed` like any other, so a listener that | |
| 311 | + * rewrites config on a revert would run here too -- resolving the host's | |
| 312 | + * constant again and erasing the define the save had just written, undoing | |
| 313 | + * the edit with a success message on screen. Listeners check this to tell | |
| 314 | + * "handed back to the host" from "promoted into our own block". (#398) | |
| 315 | + * | |
| 316 | + * Keyed by module slug, never a single global: saving module A must not | |
| 317 | + * silence a revert that A's own save handler performs on module B. A bare | |
| 318 | + * flag would swallow it as a promotion and leave our define in place -- | |
| 319 | + * the panel-revert no-op, reintroduced through a side door. | |
| 320 | + * | |
| 321 | + * @var array<string,bool> | |
| 322 | + */ | |
| 323 | + private static array $promoting = array(); | |
| 324 | + | |
| 325 | + /** @see self::$promoting */ | |
| 326 | + public static function is_promoting( string $slug ): bool { | |
| 327 | + return ! empty( self::$promoting[ $slug ] ); | |
| 328 | + } | |
| 329 | + | |
| 330 | + /** | |
| 331 | + * Has an admin deliberately overridden this constant-pinned field? | |
| 332 | + * | |
| 333 | + * Overriding is a considered act: the panel states that the host set the | |
| 334 | + * value and that overriding shadows their future changes, and only then | |
| 335 | + * unlocks the field (DESIGN.md §24.36). Once taken, the field behaves like | |
| 336 | + * any other -- editable, stored in the option row, and exempt from the | |
| 337 | + * enable() guard that otherwise protects a host's define. (#398) | |
| 338 | + */ | |
| 339 | + public static function is_overridden( string $slug, string $key ): bool { | |
| 340 | + $all = get_option( self::OVERRIDE_OPTION, array() ); | |
| 341 | + if ( ! is_array( $all ) || ! in_array( $key, (array) ( $all[ $slug ] ?? array() ), true ) ) { | |
| 342 | + return false; | |
| 343 | + } | |
| 344 | + | |
| 345 | + // An override only means something while a FOREIGN constant is actually | |
| 346 | + // pinning the field. Two ways the entry goes stale, both silent: | |
| 347 | + // | |
| 348 | + // - The host removes their define. There is nothing left to override, | |
| 349 | + // and a lingering entry would disable the lock the moment they put | |
| 350 | + // it back -- the field would stay editable with nothing on screen | |
| 351 | + // saying why. | |
| 352 | + // - enable() writes our own XSPEED_OC_* copy of the admin's value. If | |
| 353 | + // that counted as "still overriding", Revert would hand the field to | |
| 354 | + // our own snapshot instead of back to the host, and the row would | |
| 355 | + // name a constant xSpeed wrote rather than the one being displaced. | |
| 356 | + // | |
| 357 | + // So the override is scoped to a foreign constant being present. (#398) | |
| 358 | + return null !== self::foreign_constant( $slug, $key ); | |
| 359 | + } | |
| 360 | + | |
| 361 | + /** | |
| 362 | + * The constant pinning this field that xSpeed did not write itself. | |
| 363 | + * | |
| 364 | + * Our own `XSPEED_*` defines are ours to rewrite and never something an | |
| 365 | + * admin needs protecting from; only somebody else's define -- the host's | |
| 366 | + * `WP_REDIS_*` -- is what an override displaces. (#398) | |
| 367 | + */ | |
| 368 | + public static function foreign_constant( string $slug, string $key ): ?string { | |
| 369 | + $module = Module_Registry::get( $slug ); | |
| 370 | + if ( ! $module ) { | |
| 371 | + return null; | |
| 372 | + } | |
| 373 | + $spec = $module->settings_schema()[ $key ] ?? null; | |
| 374 | + if ( ! is_array( $spec ) ) { | |
| 375 | + return null; | |
| 376 | + } | |
| 377 | + $ours = self::self_written_constants(); | |
| 378 | + foreach ( (array) ( $spec['constants'] ?? array() ) as $name ) { | |
| 379 | + /* | |
| 380 | + * Ownership is decided by WHERE the define sits, not by its name -- | |
| 381 | + * the same rule our_constants() implements. Skipping every | |
| 382 | + * `XSPEED_*` by prefix got the hand-pasted case backwards: a user | |
| 383 | + * who pastes our snippet themselves owns those defines, and | |
| 384 | + * treating them as ours let wp_config_block() emit a SECOND define | |
| 385 | + * of the same constant inside our block. PHP then warns | |
| 386 | + * "already defined" on every request, the user's value wins because | |
| 387 | + * it came first, and the panel shows ours. (#398) | |
| 388 | + */ | |
| 389 | + if ( ! is_string( $name ) || in_array( $name, $ours, true ) ) { | |
| 390 | + continue; | |
| 391 | + } | |
| 392 | + /* | |
| 393 | + * WP_CACHE_KEY_SALT is not a foreign authority to displace ours | |
| 394 | + * (#430). Unlike WP_REDIS_PREFIX, it is not a host declaration of | |
| 395 | + * the cache namespace -- it is WordPress's own cache-uniqueness | |
| 396 | + * salt, present on nearly every install and usually random. Treating | |
| 397 | + * it as one made effective_constant() prefer a random WordPress salt | |
| 398 | + * over the correct XSPEED_OC_SALT that xCloud writes beside it, so | |
| 399 | + * every write landed outside the ACL namespace (NOPERM). Mirrors the | |
| 400 | + * drop-in's salt resolution so panel and runtime agree. | |
| 401 | + */ | |
| 402 | + if ( 'WP_CACHE_KEY_SALT' === $name ) { | |
| 403 | + continue; | |
| 404 | + } | |
| 405 | + $single = self::constant_source( array( 'constants' => array( $name ) ) + $spec ); | |
| 406 | + if ( null !== $single ) { | |
| 407 | + return $single; | |
| 408 | + } | |
| 409 | + } | |
| 410 | + return null; | |
| 411 | + } | |
| 412 | + | |
| 413 | + /** | |
| 414 | + * Is this field listed in the override option, regardless of whether a | |
| 415 | + * constant is currently pinning it? | |
| 416 | + * | |
| 417 | + * `is_overridden()` asks the question callers usually mean -- "is this | |
| 418 | + * override doing anything right now" -- which is false once the constant | |
| 419 | + * it displaced is gone. This is the raw list membership, for the two places | |
| 420 | + * that manage the list itself. | |
| 421 | + */ | |
| 422 | + private static function has_override_entry( string $slug, string $key ): bool { | |
| 423 | + $all = get_option( self::OVERRIDE_OPTION, array() ); | |
| 424 | + return is_array( $all ) && in_array( $key, (array) ( $all[ $slug ] ?? array() ), true ); | |
| 425 | + } | |
| 426 | + | |
| 427 | + /** | |
| 428 | + * Every overridden field for a module. | |
| 429 | + * | |
| 430 | + * @return string[] | |
| 431 | + */ | |
| 432 | + public static function overridden_keys( string $slug ): array { | |
| 433 | + $all = get_option( self::OVERRIDE_OPTION, array() ); | |
| 434 | + if ( ! is_array( $all ) ) { | |
| 435 | + return array(); | |
| 436 | + } | |
| 437 | + return array_values( array_filter( (array) ( $all[ $slug ] ?? array() ), 'is_string' ) ); | |
| 438 | + } | |
| 439 | + | |
| 440 | + /** | |
| 441 | + * Take a pinned field back, or hand it to the constant again. | |
| 442 | + * | |
| 443 | + * Reverting deliberately leaves the stored value in place: the constant | |
| 444 | + * outranks it the moment the override lifts, so the row is inert, and | |
| 445 | + * keeping it means a second override does not start from a blank box. | |
| 446 | + * | |
| 447 | + * @param bool $on True to override, false to revert. | |
| 448 | + */ | |
| 449 | + public static function set_override( string $slug, string $key, bool $on ): void { | |
| 450 | + $module = Module_Registry::get( $slug ); | |
| 451 | + if ( ! $module || ! array_key_exists( $key, $module->settings_schema() ) ) { | |
| 452 | + return; | |
| 453 | + } | |
| 454 | + // Seed the option row from the value currently in force, BEFORE the | |
| 455 | + // override lifts. Without this the field falls back to its schema | |
| 456 | + // default the moment it unlocks -- so an admin who took the field over | |
| 457 | + // to tweak the host would find it silently reset to 127.0.0.1, and a | |
| 458 | + // save would write that. Take-over means "carry on from here", not | |
| 459 | + // "start again". Skipped when a row already exists, so a second | |
| 460 | + // override still resumes from what was last typed. (#398) | |
| 461 | + if ( $on && ! self::has_override_entry( $slug, $key ) ) { | |
| 462 | + $stored = get_option( self::option_key( $slug ), array() ); | |
| 463 | + if ( ! is_array( $stored ) ) { | |
| 464 | + $stored = array(); | |
| 465 | + } | |
| 466 | + if ( ! array_key_exists( $key, $stored ) ) { | |
| 467 | + $spec = $module->settings_schema()[ $key ]; | |
| 468 | + $constant = self::constant_source( $spec ); | |
| 469 | + if ( null !== $constant ) { | |
| 470 | + $stored[ $key ] = self::coerce( self::constant_value( $key, $constant, $spec ), $spec ); | |
| 471 | + if ( 'secret' === ( $spec['type'] ?? '' ) ) { | |
| 472 | + $stored[ $key ] = self::encrypt_for_storage( (string) $stored[ $key ] ); | |
| 473 | + } | |
| 474 | + // Stamp the version this row was written against. Without it | |
| 475 | + // run_migrations() sees a row with no `_version`, reads that | |
| 476 | + // as 0.0.0, and replays EVERY migration over data that never | |
| 477 | + // held a pre-migration shape -- a seed is not an upgrade. | |
| 478 | + if ( ! isset( $stored['_version'] ) ) { | |
| 479 | + $stored['_version'] = $module->version(); | |
| 480 | + } | |
| 481 | + update_option( self::option_key( $slug ), $stored ); | |
| 482 | + } | |
| 483 | + } | |
| 484 | + } | |
| 485 | + | |
| 486 | + $all = get_option( self::OVERRIDE_OPTION, array() ); | |
| 487 | + if ( ! is_array( $all ) ) { | |
| 488 | + $all = array(); | |
| 489 | + } | |
| 490 | + $keys = array_values( array_filter( (array) ( $all[ $slug ] ?? array() ), 'is_string' ) ); | |
| 491 | + | |
| 492 | + if ( $on ) { | |
| 493 | + if ( ! in_array( $key, $keys, true ) ) { | |
| 494 | + $keys[] = $key; | |
| 495 | + } | |
| 496 | + } else { | |
| 497 | + $keys = array_values( array_diff( $keys, array( $key ) ) ); | |
| 498 | + } | |
| 499 | + | |
| 500 | + if ( empty( $keys ) ) { | |
| 501 | + unset( $all[ $slug ] ); | |
| 502 | + } else { | |
| 503 | + $all[ $slug ] = $keys; | |
| 504 | + } | |
| 505 | + update_option( self::OVERRIDE_OPTION, $all ); | |
| 506 | + | |
| 507 | + /** | |
| 508 | + * A field's ownership just changed hands. | |
| 509 | + * | |
| 510 | + * A module that writes its own constants into wp-config.php has to act | |
| 511 | + * here, or a revert is a dead end: Object_Cache's enable() emits the | |
| 512 | + * admin's overridden value as XSPEED_OC_*, which outranks the host's | |
| 513 | + * define, so dropping the override entry alone leaves the field pinned | |
| 514 | + * to our stale snapshot with no route back. The listener rewrites its | |
| 515 | + * block from the settings as they now resolve. (#398) | |
| 516 | + * | |
| 517 | + * @param string $slug Module slug. | |
| 518 | + * @param string $key Field key. | |
| 519 | + * @param bool $on True when taken over, false when handed back. | |
| 520 | + */ | |
| 521 | + do_action( 'xspeed_setting_override_changed', $slug, $key, $on ); | |
| 522 | + } | |
| 523 | + | |
| 524 | + /** | |
| 525 | + * Hand a field back to the host's constant, completely. | |
| 526 | + * | |
| 527 | + * `set_override( ..., false )` only drops the override entry, which is | |
| 528 | + * enough when the constant that pinned the field is someone else's. It is | |
| 529 | + * NOT enough once we have written our own define: ours outranks the host's, | |
| 530 | + * so the field keeps our stale value, a later credential rotation is | |
| 531 | + * ignored for good, and the panel's "you can revert at any time" is a lie. | |
| 532 | + * The CLI already did the full hand-back inline; REST did not, so the | |
| 533 | + * button in the panel was a no-op on exactly the sites this matters on. | |
| 534 | + * One implementation, both callers. (#398) | |
| 535 | + * | |
| 536 | + * Returns the host constant the field was handed back to, or a WP_Error | |
| 537 | + * when there is nothing to hand it back TO -- reverting onto our own define | |
| 538 | + * would rewrite the same value into our own block and report success over a | |
| 539 | + * no-op, which is the behaviour this replaces. | |
| 540 | + * | |
| 541 | + * @return string|\WP_Error Foreign constant name, or an error. | |
| 542 | + */ | |
| 543 | + public static function revert( string $slug, string $key ) { | |
| 544 | + $module = Module_Registry::get( $slug ); | |
| 545 | + if ( ! $module || ! array_key_exists( $key, $module->settings_schema() ) ) { | |
| 546 | + return new \WP_Error( | |
| 547 | + 'xspeed_unknown_setting', | |
| 548 | + sprintf( | |
| 549 | + /* translators: %s: setting key. */ | |
| 550 | + __( 'Unknown setting: %s', 'xspeed' ), | |
| 551 | + $key | |
| 552 | + ), | |
| 553 | + array( 'status' => 400 ) | |
| 554 | + ); | |
| 555 | + } | |
| 556 | + | |
| 557 | + $foreign = self::foreign_constant( $slug, $key ); | |
| 558 | + if ( null === $foreign ) { | |
| 559 | + return new \WP_Error( | |
| 560 | + 'xspeed_nothing_to_revert', | |
| 561 | + sprintf( | |
| 562 | + /* translators: %s: setting key. */ | |
| 563 | + __( '"%s" is not set in wp-config.php by your host, so there is nothing to revert to. Set it to the value you want instead.', 'xspeed' ), | |
| 564 | + $key | |
| 565 | + ), | |
| 566 | + array( 'status' => 409 ) | |
| 567 | + ); | |
| 568 | + } | |
| 569 | + | |
| 570 | + self::set_override( $slug, $key, false ); | |
| 571 | + | |
| 572 | + /* | |
| 573 | + * Drop our stored value as well as the override entry. Leaving the row | |
| 574 | + * in place is what kept our define being re-emitted on the next block | |
| 575 | + * write, so the field never actually returned to the host. The | |
| 576 | + * `xspeed_setting_override_changed` action fired by set_override() is | |
| 577 | + * what rewrites the block from the settings as they now resolve. | |
| 578 | + */ | |
| 579 | + $row = get_option( self::option_key( $slug ), array() ); | |
| 580 | + if ( is_array( $row ) && array_key_exists( $key, $row ) ) { | |
| 581 | + unset( $row[ $key ] ); | |
| 582 | + update_option( self::option_key( $slug ), $row ); | |
| 583 | + } | |
| 584 | + | |
| 585 | + return $foreign; | |
| 586 | + } | |
| 587 | + | |
| 588 | + /** | |
| 589 | + * A constant's value as this field should read it. | |
| 590 | + * | |
| 591 | + * Almost always the constant verbatim. The exception is a constant that | |
| 592 | + * carries a credential PAIR in one define -- managed hosts provisioning | |
| 593 | + * Redis ACL users ship | |
| 594 | + * | |
| 595 | + * define( 'WP_REDIS_PASSWORD', array( 'acl_user', 's3cret' ) ); | |
| 596 | + * | |
| 597 | + * and casting that to string yields "Array" plus a notice, so the site | |
| 598 | + * authenticates with garbage. A field says which half it wants by | |
| 599 | + * declaring `constant_pair => 'user'|'password'`; the split is positional | |
| 600 | + * so an associative pair works too. Declared in the schema rather than | |
| 601 | + * keyed off field names, so the next module with a paired credential | |
| 602 | + * inherits it. (#398) | |
| 603 | + * | |
| 604 | + * @param string $key Schema field key (for context in filters). | |
| 605 | + * @param string $constant Winning constant name. | |
| 606 | + * @param array $spec Field spec from settings_schema(). | |
| 607 | + * @return mixed | |
| 608 | + */ | |
| 609 | + private static function constant_value( string $key, string $constant, array $spec ) { | |
| 610 | + // A `$`-prefixed source is a wp-config global, not a constant. | |
| 611 | + $value = 0 === strpos( $constant, '$' ) | |
| 612 | + ? self::global_value( $spec ) | |
| 613 | + : constant( $constant ); | |
| 614 | + $part = $spec['constant_pair'] ?? ''; | |
| 615 | + | |
| 616 | + if ( ! is_array( $value ) || ( 'user' !== $part && 'password' !== $part ) ) { | |
| 617 | + return $value; | |
| 618 | + } | |
| 619 | + | |
| 620 | + // Only scalars: a nested array would stringify to "Array" plus a PHP | |
| 621 | + // warning, so the site would authenticate with that literal. Dropping | |
| 622 | + // non-scalars means a malformed define reads as absent rather than as | |
| 623 | + // a wrong credential. | |
| 624 | + $parts = array_values( array_filter( $value, 'is_scalar' ) ); | |
| 625 | + if ( 'user' === $part ) { | |
| 626 | + // A one-element array is a password with no ACL user. | |
| 627 | + return count( $parts ) > 1 ? (string) $parts[0] : ''; | |
| 628 | + } | |
| 629 | + return (string) ( count( $parts ) > 1 ? $parts[1] : ( $parts[0] ?? '' ) ); | |
| 630 | + } | |
| 631 | + | |
| 632 | + /** | |
| 633 | + * Where each of a module's settings actually came from. | |
| 634 | + * | |
| 635 | + * Returns one entry per schema field: | |
| 636 | + * [ 'source' => 'constant'|'db'|'default', 'constant' => ?string ] | |
| 637 | + * | |
| 638 | + * One map feeds every surface that has to be honest about provenance -- | |
| 639 | + * the panel's read-only indicator, `wp xspeed objcache status`, and the | |
| 640 | + * refusal message when a write targets a pinned field -- so they cannot | |
| 641 | + * drift apart. (#398) | |
| 642 | + * | |
| 643 | + * @return array<string,array{source:string,constant:?string}> | |
| 644 | + */ | |
| 645 | + /** | |
| 646 | + * Constant names xSpeed wrote itself, as opposed to ones the host defined. | |
| 647 | + * | |
| 648 | + * Ownership is decided by WHERE the define sits -- inside our fenced block | |
| 649 | + * in wp-config.php, or anywhere else -- never by its name. A user who | |
| 650 | + * pastes our snippet by hand ends up with `XSPEED_OC_*` defines that are | |
| 651 | + * theirs, not ours, and must not be silently rewritten. | |
| 652 | + * | |
| 653 | + * Filterable so a module that owns constants can answer for itself without | |
| 654 | + * this class having to know about it. | |
| 655 | + * | |
| 656 | + * @return string[] | |
| 657 | + */ | |
| 658 | + /** | |
| 659 | + * The constant that BLOCKS writing this field, or null when it is writable. | |
| 660 | + * | |
| 661 | + * Same resolution as effective_constant(), minus the constants xSpeed | |
| 662 | + * wrote itself: those are this plugin's own storage, so a save rewrites | |
| 663 | + * them rather than being refused. Only a define somebody else put in | |
| 664 | + * wp-config.php makes a field read-only. | |
| 665 | + * | |
| 666 | + * Read paths keep using effective_constant() -- what the site RUNS on is | |
| 667 | + * the winning constant either way; this only answers "may the panel write | |
| 668 | + * here". (#398) | |
| 669 | + * | |
| 670 | + * @param array $spec Field spec from settings_schema(). | |
| 671 | + */ | |
| 672 | + public static function write_blocking_constant( string $slug, string $key, array $spec ): ?string { | |
| 673 | + $constant = self::effective_constant( $slug, $key, $spec ); | |
| 674 | + if ( null === $constant ) { | |
| 675 | + return null; | |
| 676 | + } | |
| 677 | + return in_array( $constant, self::self_written_constants(), true ) ? null : $constant; | |
| 678 | + } | |
| 679 | + | |
| 680 | + public static function self_written_constants(): array { | |
| 681 | + $names = array(); | |
| 682 | + if ( class_exists( '\\XSpeed\\Object_Cache' ) ) { | |
| 683 | + $names = \XSpeed\Object_Cache::our_constants(); | |
| 684 | + } | |
| 685 | + | |
| 686 | + /** | |
| 687 | + * Filter: xspeed_self_written_constants | |
| 688 | + * | |
| 689 | + * @param string[] $names Constant names xSpeed wrote into wp-config.php. | |
| 690 | + */ | |
| 691 | + return (array) apply_filters( 'xspeed_self_written_constants', $names ); | |
| 692 | + } | |
| 693 | + | |
| 694 | + public static function origins( string $slug ): array { | |
| 695 | + $module = Module_Registry::get( $slug ); | |
| 696 | + if ( ! $module ) { | |
| 697 | + return array(); | |
| 698 | + } | |
| 699 | + $stored = get_option( self::option_key( $slug ), array() ); | |
| 700 | + if ( ! is_array( $stored ) ) { | |
| 701 | + $stored = array(); | |
| 702 | + } | |
| 703 | + | |
| 704 | + $origins = array(); | |
| 705 | + $ours = self::self_written_constants(); | |
| 706 | + foreach ( $module->settings_schema() as $key => $spec ) { | |
| 707 | + $constant = self::effective_constant( $slug, $key, $spec ); | |
| 708 | + /* | |
| 709 | + * A constant WE wrote is not a lock. It is this module's own | |
| 710 | + * storage -- wp-config.php is simply where the drop-in can read it | |
| 711 | + * before WordPress loads -- so the field stays editable and saving | |
| 712 | + * rewrites it. | |
| 713 | + * | |
| 714 | + * Reporting it as 'constant' is what made enabling the object cache | |
| 715 | + * lock its own settings screen, tell the user seven fields were | |
| 716 | + * "set in wp-config.php" when they had never opened that file, and | |
| 717 | + * leave the take-it-back control unable to unlock anything. (#398) | |
| 718 | + */ | |
| 719 | + if ( null !== $constant && in_array( $constant, $ours, true ) ) { | |
| 720 | + $origins[ $key ] = array( | |
| 721 | + 'source' => 'db', | |
| 722 | + 'constant' => null, | |
| 723 | + 'overriding' => self::foreign_constant( $slug, $key ), | |
| 724 | + ); | |
| 725 | + continue; | |
| 726 | + } | |
| 727 | + if ( null !== $constant ) { | |
| 728 | + $origins[ $key ] = array( | |
| 729 | + 'source' => 'constant', | |
| 730 | + 'constant' => $constant, | |
| 731 | + 'overriding' => null, | |
| 732 | + ); | |
| 733 | + continue; | |
| 734 | + } | |
| 735 | + // An overridden field reports its real source -- the option row -- | |
| 736 | + // but still names the constant it is shadowing, so the panel can | |
| 737 | + // say WHAT is being overridden and offer to hand it back. Without | |
| 738 | + // this the row is indistinguishable from a field no constant ever | |
| 739 | + // touched. (#398) | |
| 740 | + $origins[ $key ] = array( | |
| 741 | + 'source' => array_key_exists( $key, $stored ) ? 'db' : 'default', | |
| 742 | + 'constant' => null, | |
| 743 | + 'overriding' => self::foreign_constant( $slug, $key ), | |
| 744 | + ); | |
| 745 | + } | |
| 746 | + return $origins; | |
| 747 | + } | |
| 748 | + | |
| 749 | + /** | |
| 750 | + * Field keys this module cannot persist because a constant pins them. | |
| 751 | + * | |
| 752 | + * @return string[] | |
| 753 | + */ | |
| 754 | + public static function locked_keys( string $slug ): array { | |
| 755 | + $module = Module_Registry::get( $slug ); | |
| 756 | + if ( ! $module ) { | |
| 757 | + return array(); | |
| 758 | + } | |
| 759 | + $locked = array(); | |
| 760 | + foreach ( $module->settings_schema() as $key => $spec ) { | |
| 761 | + if ( null !== self::write_blocking_constant( $slug, $key, $spec ) ) { | |
| 762 | + $locked[] = $key; | |
| 763 | + } | |
| 764 | + } | |
| 765 | + return $locked; | |
| 766 | + } | |
| 767 | + | |
| 768 | + /** | |
| 769 | + * The subset of an incoming patch that targets constant-pinned fields. | |
| 770 | + * | |
| 771 | + * Callers use this to fail loudly. Writing such a key would persist a row | |
| 772 | + * that get() will never read back -- the worst outcome for an automation, | |
| 773 | + * which reports success and changes nothing. (#398) | |
| 774 | + * | |
| 775 | + * Only a key whose submitted value DIFFERS from the resolved one counts. The | |
| 776 | + * dashboard saves a panel by posting every field it rendered, so a pinned | |
| 777 | + * field rides along in the patch on every save; treating that echo as an | |
| 778 | + * attempted write would 409 the whole request and make the panel | |
| 779 | + * unsaveable on exactly the host-provisioned site this feature exists for. | |
| 780 | + * An echo of the effective value asks for no change, so it is allowed | |
| 781 | + * through and dropped harmlessly by update(). (#398) | |
| 782 | + * | |
| 783 | + * @param array<string,mixed> $input Proposed settings patch. | |
| 784 | + * @return array<string,string> Field key => winning constant name. | |
| 785 | + */ | |
| 786 | + public static function locked_in_input( string $slug, array $input ): array { | |
| 787 | + $module = Module_Registry::get( $slug ); | |
| 788 | + if ( ! $module ) { | |
| 789 | + return array(); | |
| 790 | + } | |
| 791 | + $resolved = self::get( $slug ); | |
| 792 | + $public = self::get_public( $slug ); | |
| 793 | + $hits = array(); | |
| 794 | + foreach ( $module->settings_schema() as $key => $spec ) { | |
| 795 | + if ( ! array_key_exists( $key, $input ) ) { | |
| 796 | + continue; | |
| 797 | + } | |
| 798 | + $constant = self::write_blocking_constant( $slug, $key, $spec ); | |
| 799 | + if ( null === $constant ) { | |
| 800 | + continue; | |
| 801 | + } | |
| 802 | + | |
| 803 | + // Compare against the coerced form, so 6379 and "6379" from a JSON | |
| 804 | + // body agree, and against the MASKED form too: a secret's effective | |
| 805 | + // value never leaves the server, so the client can only ever echo | |
| 806 | + // the mask it was given. | |
| 807 | + [ $coerced ] = self::validate_field( $input[ $key ], $spec ); | |
| 808 | + $submitted = $input[ $key ]; | |
| 809 | + $echoes = ( $coerced === ( $resolved[ $key ] ?? null ) ) | |
| 810 | + || ( is_string( $submitted ) && $submitted === ( $public[ $key ] ?? null ) ) | |
| 811 | + || ( self::is_secret_field( $key, $spec ) && is_string( $submitted ) && self::is_masked_secret( $submitted ) ); | |
| 812 | + | |
| 813 | + if ( ! $echoes ) { | |
| 814 | + $hits[ $key ] = $constant; | |
| 815 | + } | |
| 816 | + } | |
| 817 | + return $hits; | |
| 818 | + } | |
| 819 | + | |
| 820 | + /** | |
| 68 | 821 | * Validate input against the module's schema, merge over stored values, |
| 69 | 822 | * and persist. Returns the final clean array. Unknown keys are stripped |
| 70 | 823 | * silently. Out-of-range / wrong-type values fall back to the previous |
| 71 | 824 | * stored value (or default). |
| @@ -77,13 +830,43 @@ | ||
| 77 | 830 | } |
| 78 | 831 | $schema = $module->settings_schema(); |
| 79 | 832 | $current = self::get( $slug ); |
| 80 | 833 | |
| 834 | + // An MCP agent must not silently rewrite credentials — repointing the | |
| 835 | + // Cloudflare or object-cache backend at an attacker endpoint — unless the | |
| 836 | + // connection was explicitly granted the `configure` scope. Strip secret | |
| 837 | + // fields from an unprivileged MCP write here so every write path (the | |
| 838 | + // update_settings tool AND run_command → CLI) is covered at one choke | |
| 839 | + // point. The tool handler surfaces the refusal as a clear error. (#116) | |
| 840 | + if ( self::mcp_write_blocked() ) { | |
| 841 | + foreach ( $schema as $key => $spec ) { | |
| 842 | + if ( self::is_secret_field( $key, $spec ) ) { | |
| 843 | + unset( $input[ $key ] ); | |
| 844 | + } | |
| 845 | + } | |
| 846 | + } | |
| 847 | + | |
| 81 | 848 | $clean = $current; |
| 82 | 849 | foreach ( $schema as $key => $spec ) { |
| 83 | 850 | if ( ! array_key_exists( $key, $input ) ) { |
| 84 | 851 | continue; |
| 85 | 852 | } |
| 853 | + // A constant pins this field, so the write cannot take effect. Drop | |
| 854 | + // it rather than storing a row get() will never read. REST and CLI | |
| 855 | + // call locked_in_input() first and refuse the request outright with | |
| 856 | + // the constant's name; this is the choke-point backstop for any | |
| 857 | + // caller that reaches update() directly. (#398) | |
| 858 | + if ( null !== self::write_blocking_constant( $slug, $key, $spec ) ) { | |
| 859 | + continue; | |
| 860 | + } | |
| 861 | + // A secret field whose incoming value is the masked placeholder means | |
| 862 | + // the client is echoing back what get_public() sent, not setting a new | |
| 863 | + // credential — keep the stored value so an unrelated save on the same | |
| 864 | + // panel never wipes the key. An empty value is NOT a mask echo: it's a | |
| 865 | + // deliberate clear and flows through to remove the credential. (#115) | |
| 866 | + if ( self::is_secret_field( $key, $spec ) && self::is_masked_secret( (string) $input[ $key ] ) ) { | |
| 867 | + continue; | |
| 868 | + } | |
| 86 | 869 | [ $value, $valid ] = self::validate_field( $input[ $key ], $spec ); |
| 87 | 870 | if ( $valid ) { |
| 88 | 871 | $clean[ $key ] = $value; |
| 89 | 872 | } |
| @@ -101,51 +884,252 @@ | ||
| 101 | 884 | $clean[ $key ] = $input[ $key ]; |
| 102 | 885 | } |
| 103 | 886 | } |
| 104 | 887 | |
| 105 | - $clean['_version'] = $module->version(); | |
| 106 | - update_option( self::option_key( $slug ), $clean ); | |
| 107 | - | |
| 108 | - // Strip the internal _version key from the returned array. | |
| 109 | - unset( $clean['_version'] ); | |
| 110 | - | |
| 111 | 888 | // Change annotation (issue #45): every real mutation — from the UI, |
| 112 | 889 | // REST, CLI, or an MCP agent — lands in the activity log with the |
| 113 | 890 | // old→new diff and its source channel, so the dashboard can tell the |
| 114 | 891 | // causal story ("expiry raised → hit ratio climbed"). |
| 115 | - self::log_changes( $slug, $current, $clean, array_keys( $schema ) ); | |
| 892 | + // | |
| 893 | + // $clean holds plaintext secrets (carried from $current, which get() | |
| 894 | + // decrypts, or freshly validated). Log + diff BEFORE encrypting, so the | |
| 895 | + // change annotation compares like-for-like (log_changes redacts secret | |
| 896 | + // values by key anyway). The encrypted copy is persisted below. (#115) | |
| 897 | + // | |
| 898 | + // Pass the FULL schema, not just its keys — log_changes() needs each | |
| 899 | + // field's `label` to write "Disable Dashicons on Frontend" instead of | |
| 900 | + // `disable_dashicons_frontend`. The schema was already in scope here | |
| 901 | + // and was simply being discarded. (#88) | |
| 902 | + self::log_changes( $slug, $current, $clean, $schema ); | |
| 116 | 903 | |
| 117 | - return $clean; | |
| 904 | + // Encrypt at rest ONLY fields explicitly typed `secret`. This must match | |
| 905 | + // coerce(), which decrypts only for `type === 'secret'` — encrypting a | |
| 906 | + // merely name-matched `string` field (a credential a module author typed | |
| 907 | + // as string) would store ciphertext that the string coercer then hands | |
| 908 | + // back verbatim, breaking the engine. Such fields are still masked and | |
| 909 | + // write-preserved via the broader is_secret_field() (masking a plaintext | |
| 910 | + // is always safe); they just aren't encrypted until retyped to `secret`. | |
| 911 | + $stored = $clean; | |
| 912 | + foreach ( $schema as $key => $spec ) { | |
| 913 | + // A constant-pinned field never enters the option row. $clean carries | |
| 914 | + // its resolved value so the engine and the activity diff see the real | |
| 915 | + // config, but persisting it would copy a wp-config credential into the | |
| 916 | + // database -- exactly what sourcing it from a constant avoids -- and | |
| 917 | + // leave a stale row behind if the constant later changes. (#398) | |
| 918 | + if ( null !== self::write_blocking_constant( $slug, $key, $spec ) ) { | |
| 919 | + unset( $stored[ $key ] ); | |
| 920 | + continue; | |
| 921 | + } | |
| 922 | + if ( 'secret' === ( $spec['type'] ?? '' ) ) { | |
| 923 | + $stored[ $key ] = self::encrypt_for_storage( (string) ( $stored[ $key ] ?? '' ) ); | |
| 924 | + } | |
| 925 | + } | |
| 926 | + $stored['_version'] = $module->version(); | |
| 927 | + update_option( self::option_key( $slug ), $stored ); | |
| 928 | + | |
| 929 | + /** | |
| 930 | + * Settings for this module were just saved. | |
| 931 | + * | |
| 932 | + * A module whose runtime reads the config before WordPress loads -- | |
| 933 | + * the object-cache drop-in does -- mirrors the saved values into its | |
| 934 | + * own store HERE, so the panel, the CLI and the runtime cannot | |
| 935 | + * disagree. Without this a save reported success while the drop-in | |
| 936 | + * kept using the previous value. (#398) | |
| 937 | + * | |
| 938 | + * @param string $slug Module slug. | |
| 939 | + * @param array<string,mixed> $clean The settings as persisted. | |
| 940 | + */ | |
| 941 | + do_action( 'xspeed_settings_saved', $slug, $clean ); | |
| 942 | + | |
| 943 | + // An override is a single edit, not a new permanent home for the value. | |
| 944 | + // The admin unlocked the field, typed, and saved; the value now belongs | |
| 945 | + // in wp-config.php beside the one it displaced, and the field goes back | |
| 946 | + // to being locked -- reading OUR constant instead of the host's. | |
| 947 | + // | |
| 948 | + // Lifting it here rather than leaving it standing is what keeps the | |
| 949 | + // model honest. A standing override means the option row is the source | |
| 950 | + // of truth for that field, and a row cannot be read by the drop-in, | |
| 951 | + // which loads before WordPress. Promote to a constant and the panel, | |
| 952 | + // the CLI and the runtime agree again. (#398) | |
| 953 | + $settled = array(); | |
| 954 | + foreach ( array_keys( $input ) as $key ) { | |
| 955 | + if ( isset( $schema[ $key ] ) && self::is_overridden( $slug, $key ) ) { | |
| 956 | + $settled[ $key ] = $clean[ $key ] ?? null; | |
| 957 | + } | |
| 958 | + } | |
| 959 | + if ( ! empty( $settled ) ) { | |
| 960 | + /** | |
| 961 | + * Overridden fields were just saved and are about to re-lock. | |
| 962 | + * | |
| 963 | + * A module that owns constants in wp-config.php writes them HERE, | |
| 964 | + * while the values are still to hand -- once the override lifts, | |
| 965 | + * get() reads the displaced constant again and the typed value is | |
| 966 | + * gone. Passed explicitly for that reason rather than left to be | |
| 967 | + * re-read. (#398) | |
| 968 | + * | |
| 969 | + * @param string $slug Module slug. | |
| 970 | + * @param array<string,mixed> $values Field key => value just saved. | |
| 971 | + */ | |
| 972 | + do_action( 'xspeed_settings_promote_to_config', $slug, $settled ); | |
| 973 | + | |
| 974 | + // Flagged so an override_changed listener can tell this lift -- | |
| 975 | + // which promotes the value into our own block -- from a genuine | |
| 976 | + // hand-back to the host. See self::$promoting. | |
| 977 | + self::$promoting[ $slug ] = true; | |
| 978 | + try { | |
| 979 | + foreach ( array_keys( $settled ) as $key ) { | |
| 980 | + self::set_override( $slug, $key, false ); | |
| 981 | + } | |
| 982 | + } finally { | |
| 983 | + unset( self::$promoting[ $slug ] ); | |
| 984 | + } | |
| 985 | + | |
| 986 | + // Keep the saved values in the option row for THIS request. The | |
| 987 | + // constant the module just wrote is in wp-config.php but not | |
| 988 | + // defined in the running process -- constants are read at boot -- | |
| 989 | + // so get() would resolve back to the displaced value and hand the | |
| 990 | + // caller a response showing the change had not happened. The row is | |
| 991 | + // inert from the next request on, when the new constant loads and | |
| 992 | + // outranks it. (#398) | |
| 993 | + $row = get_option( self::option_key( $slug ), array() ); | |
| 994 | + if ( is_array( $row ) ) { | |
| 995 | + $carry = $settled; | |
| 996 | + foreach ( array_keys( $carry ) as $key ) { | |
| 997 | + // Same encryption the main persist path applies -- this row | |
| 998 | + // is short-lived but it is still the options table. | |
| 999 | + if ( 'secret' === ( $schema[ $key ]['type'] ?? '' ) ) { | |
| 1000 | + $carry[ $key ] = self::encrypt_for_storage( (string) $carry[ $key ] ); | |
| 1001 | + } | |
| 1002 | + } | |
| 1003 | + update_option( self::option_key( $slug ), array_merge( $row, $carry ) ); | |
| 1004 | + } | |
| 1005 | + } | |
| 1006 | + | |
| 1007 | + // Return the PUBLIC view: real non-secret values, masked secrets. This | |
| 1008 | + // is the REST/CLI/MCP response, so it must never carry credentials. (#115) | |
| 1009 | + return self::get_public( $slug ); | |
| 118 | 1010 | } |
| 119 | 1011 | |
| 120 | 1012 | /** |
| 1013 | + * The public, safe-to-serialize view of a module's settings: identical to | |
| 1014 | + * get() except every secret field is replaced by a masked hint (first/last | |
| 1015 | + * few chars, never the middle). This is what the REST GET handler, the MCP | |
| 1016 | + * read tools, and the dashboard bootstrap payload return — get() itself | |
| 1017 | + * stays plaintext for the engine. | |
| 1018 | + * | |
| 1019 | + * @return array<string,mixed> | |
| 1020 | + */ | |
| 1021 | + /** | |
| 1022 | + * Settings as stored, with schema defaults filled in and constants ignored. | |
| 1023 | + * | |
| 1024 | + * The read-back companion to get(): same shape, but it never lets a | |
| 1025 | + * constant outrank the option row. See get_public()'s `$stored_only`. | |
| 1026 | + * | |
| 1027 | + * Public because any caller that has WRITTEN in this request needs it: our | |
| 1028 | + * block has been rewritten but PHP cannot redefine the constants already | |
| 1029 | + * loaded, so the resolved read still returns the pre-save value. The object | |
| 1030 | + * cache's write probe rechecks against this for exactly that reason. (#398) | |
| 1031 | + * | |
| 1032 | + * @return array<string,mixed> | |
| 1033 | + */ | |
| 1034 | + public static function stored_with_defaults( string $slug ): array { | |
| 1035 | + $module = Module_Registry::get( $slug ); | |
| 1036 | + if ( ! $module ) { | |
| 1037 | + return array(); | |
| 1038 | + } | |
| 1039 | + $stored = get_option( self::option_key( $slug ), array() ); | |
| 1040 | + if ( ! is_array( $stored ) ) { | |
| 1041 | + $stored = array(); | |
| 1042 | + } | |
| 1043 | + $out = array(); | |
| 1044 | + foreach ( $module->settings_schema() as $key => $spec ) { | |
| 1045 | + if ( array_key_exists( $key, $stored ) ) { | |
| 1046 | + $out[ $key ] = self::coerce( $stored[ $key ], $spec ); | |
| 1047 | + continue; | |
| 1048 | + } | |
| 1049 | + /* | |
| 1050 | + * Not in the row. For a field a HOST constant pins that is the | |
| 1051 | + * normal state -- update() strips those before writing -- and the | |
| 1052 | + * schema default would be a lie (an empty password for a field the | |
| 1053 | + * site authenticates with). Report the effective value there. | |
| 1054 | + */ | |
| 1055 | + $constant = self::effective_constant( $slug, $key, $spec ); | |
| 1056 | + $out[ $key ] = null !== $constant | |
| 1057 | + ? self::constant_value( $key, $constant, $spec ) | |
| 1058 | + : ( $spec['default'] ?? null ); | |
| 1059 | + } | |
| 1060 | + | |
| 1061 | + // Same carry-through get() does, so a caller asking for the stored | |
| 1062 | + // state does not silently lose a module's out-of-schema keys. | |
| 1063 | + foreach ( $module->preserved_keys() as $key ) { | |
| 1064 | + if ( array_key_exists( $key, $stored ) ) { | |
| 1065 | + $out[ $key ] = $stored[ $key ]; | |
| 1066 | + } | |
| 1067 | + } | |
| 1068 | + return $out; | |
| 1069 | + } | |
| 1070 | + | |
| 1071 | + public static function get_public( string $slug, bool $stored_only = false ): array { | |
| 1072 | + $module = Module_Registry::get( $slug ); | |
| 1073 | + if ( ! $module ) { | |
| 1074 | + return array(); | |
| 1075 | + } | |
| 1076 | + /* | |
| 1077 | + * `$stored_only` answers "what did we just persist", not "what is the | |
| 1078 | + * site running on". A caller that has written in THIS request needs it: | |
| 1079 | + * the save also rewrites our wp-config block, but the constants for | |
| 1080 | + * this request are already defined and PHP cannot redefine them, so the | |
| 1081 | + * normal read would resolve the pre-write constant and report the save | |
| 1082 | + * as a no-op. (#398) | |
| 1083 | + */ | |
| 1084 | + $settings = $stored_only ? self::stored_with_defaults( $slug ) : self::get( $slug ); | |
| 1085 | + foreach ( $module->settings_schema() as $key => $spec ) { | |
| 1086 | + if ( self::is_secret_field( $key, $spec ) && array_key_exists( $key, $settings ) ) { | |
| 1087 | + $settings[ $key ] = self::mask_secret_value( (string) $settings[ $key ] ); | |
| 1088 | + } | |
| 1089 | + } | |
| 1090 | + return $settings; | |
| 1091 | + } | |
| 1092 | + | |
| 1093 | + /** | |
| 121 | 1094 | * Record changed schema fields as one activity event. No-op when |
| 122 | 1095 | * nothing actually changed (idempotent re-saves stay silent). |
| 123 | 1096 | * |
| 124 | - * @param string $slug Module slug. | |
| 125 | - * @param array $before Settings before the write. | |
| 126 | - * @param array $after Settings after the write. | |
| 127 | - * @param string[] $schema_keys Keys eligible to diff. | |
| 1097 | + * @param string $slug Module slug. | |
| 1098 | + * @param array $before Settings before the write. | |
| 1099 | + * @param array $after Settings after the write. | |
| 1100 | + * @param array $schema Full settings schema — used for each field's label. | |
| 128 | 1101 | */ |
| 129 | - private static function log_changes( string $slug, array $before, array $after, array $schema_keys ): void { | |
| 1102 | + private static function log_changes( string $slug, array $before, array $after, array $schema ): void { | |
| 130 | 1103 | if ( ! class_exists( '\\XSpeed\\Activity_Log' ) ) { |
| 131 | 1104 | return; |
| 132 | 1105 | } |
| 133 | 1106 | $diffs = array(); |
| 134 | - foreach ( $schema_keys as $key ) { | |
| 1107 | + foreach ( array_keys( $schema ) as $key ) { | |
| 135 | 1108 | $old = $before[ $key ] ?? null; |
| 136 | 1109 | $new = $after[ $key ] ?? null; |
| 137 | 1110 | if ( $old === $new ) { |
| 138 | 1111 | continue; |
| 139 | 1112 | } |
| 140 | - if ( self::is_secret_key( $key ) ) { | |
| 1113 | + | |
| 1114 | + // The schema already declares a human label for every field — the | |
| 1115 | + // same one rendered a few inches away on the settings screen. The | |
| 1116 | + // feed used the raw storage key instead, so users read | |
| 1117 | + // `disable_dashicons_frontend` rather than "Disable Dashicons on | |
| 1118 | + // Frontend". Fall back to the key when a schema has no label, so | |
| 1119 | + // an entry is never blank. (#88) | |
| 1120 | + $label = isset( $schema[ $key ]['label'] ) && is_string( $schema[ $key ]['label'] ) && '' !== $schema[ $key ]['label'] | |
| 1121 | + ? $schema[ $key ]['label'] | |
| 1122 | + : $key; | |
| 1123 | + | |
| 1124 | + if ( self::is_redacted_key( $key ) ) { | |
| 141 | 1125 | // Never record the value itself — the annotation is served to |
| 142 | - // the dashboard by the trend endpoints, so a token written | |
| 143 | - // here would be readable from the REST payload. | |
| 144 | - $diffs[] = sprintf( '%s changed', $key ); | |
| 1126 | + // the dashboard by the trend endpoints, so anything written | |
| 1127 | + // here is readable by any user who can load the dashboard. | |
| 1128 | + $diffs[] = sprintf( '%s changed', $label ); | |
| 145 | 1129 | continue; |
| 146 | 1130 | } |
| 147 | - $diffs[] = sprintf( '%s %s→%s', $key, self::describe_value( $old ), self::describe_value( $new ) ); | |
| 1131 | + $diffs[] = sprintf( '%s %s→%s', $label, self::describe_value( $old ), self::describe_value( $new ) ); | |
| 148 | 1132 | } |
| 149 | 1133 | if ( empty( $diffs ) ) { |
| 150 | 1134 | return; |
| 151 | 1135 | } |
| @@ -150,13 +1134,39 @@ | ||
| 150 | 1134 | return; |
| 151 | 1135 | } |
| 152 | 1136 | Activity_Log::record( |
| 153 | 1137 | 'settings_changed', |
| 154 | - sprintf( '%s: %s (via %s)', $slug, implode( ', ', array_slice( $diffs, 0, 5 ) ), self::source_channel() ) | |
| 1138 | + sprintf( '%s: %s (via %s)', self::module_label( $slug ), implode( ', ', array_slice( $diffs, 0, 5 ) ), self::source_channel() ) | |
| 155 | 1139 | ); |
| 156 | 1140 | } |
| 157 | 1141 | |
| 158 | 1142 | /** |
| 1143 | + * A module's display name for the activity feed, e.g. `gzip` → | |
| 1144 | + * "Compression". | |
| 1145 | + * | |
| 1146 | + * Resolved through the module registry rather than a lookup table here, | |
| 1147 | + * so Pro modules (feed-cache, search-cache, …) get their labels from the | |
| 1148 | + * same path — Pro persists through this class and contributes no logging | |
| 1149 | + * code of its own. | |
| 1150 | + * | |
| 1151 | + * Falls back to the raw slug when the module isn't registered or declares | |
| 1152 | + * no label; an entry is never blank. | |
| 1153 | + */ | |
| 1154 | + private static function module_label( string $slug ): string { | |
| 1155 | + if ( ! class_exists( '\\XSpeed\\Module_Registry' ) ) { | |
| 1156 | + return $slug; | |
| 1157 | + } | |
| 1158 | + $module = Module_Registry::get( $slug ); | |
| 1159 | + if ( ! $module ) { | |
| 1160 | + return $slug; | |
| 1161 | + } | |
| 1162 | + $meta = $module->ui_metadata(); | |
| 1163 | + return ( isset( $meta['label'] ) && is_string( $meta['label'] ) && '' !== $meta['label'] ) | |
| 1164 | + ? $meta['label'] | |
| 1165 | + : $slug; | |
| 1166 | + } | |
| 1167 | + | |
| 1168 | + /** | |
| 159 | 1169 | * Setting keys whose VALUE must never reach the activity log. The log is |
| 160 | 1170 | * surfaced by the dashboard trend endpoints, so anything recorded here is |
| 161 | 1171 | * readable by any user who can load the dashboard. |
| 162 | 1172 | * |
| @@ -166,11 +1176,377 @@ | ||
| 166 | 1176 | * |
| 167 | 1177 | * @param string $key Schema key, e.g. 'api_token'. |
| 168 | 1178 | */ |
| 169 | 1179 | public static function is_secret_key( string $key ): bool { |
| 170 | - return 1 === preg_match( '/(token|password|secret|api_key|passwd|private_key|credential)/i', $key ); | |
| 1180 | + // `license_key` is matched explicitly: the pattern requires `api_key` | |
| 1181 | + // rather than a bare `key` so that `key_prefix` (an ordinary, | |
| 1182 | + // useful-to-see setting) isn't swallowed, which left a real license | |
| 1183 | + // key printing in plaintext. | |
| 1184 | + return 1 === preg_match( '/(token|password|secret|api_key|license_key|passwd|private_key|credential)/i', $key ); | |
| 171 | 1185 | } |
| 172 | 1186 | |
| 1187 | + /** | |
| 1188 | + * Setting keys whose value is withheld from the activity feed. | |
| 1189 | + * | |
| 1190 | + * Secrets (above) plus infrastructure IDENTIFIERS. `redis_password` was | |
| 1191 | + * correctly redacted while `redis_user`, `redis_host` and `key_prefix` | |
| 1192 | + * were written out in full — and the feed is served to any user who can | |
| 1193 | + * load the dashboard, not just admins (see the trend endpoints). | |
| 1194 | + * | |
| 1195 | + * A Redis hostname and username are most of a credential, and they | |
| 1196 | + * describe internal infrastructure that has no business being readable by | |
| 1197 | + * a subscriber. The feed's job — "this setting changed, when, and by | |
| 1198 | + * whom" — is served without printing the value. (#88) | |
| 1199 | + * | |
| 1200 | + * Deliberately matched on the key NAME: once stringified, a hostname is | |
| 1201 | + * indistinguishable from any other short string. Pure — unit-tested. | |
| 1202 | + * | |
| 1203 | + * @param string $key Schema key, e.g. 'redis_host'. | |
| 1204 | + */ | |
| 1205 | + public static function is_redacted_key( string $key ): bool { | |
| 1206 | + if ( self::is_secret_key( $key ) ) { | |
| 1207 | + return true; | |
| 1208 | + } | |
| 1209 | + | |
| 1210 | + // Deliberately an explicit list rather than a broad word match. A | |
| 1211 | + // pattern like /(host|user|prefix|port)/ also swallows | |
| 1212 | + // `bypass_user_agents`, `preconnect_hosts` and `excluded_urls` — | |
| 1213 | + // ordinary user-facing settings whose values are exactly what makes | |
| 1214 | + // the feed useful. Over-redacting is a quieter failure than leaking, | |
| 1215 | + // but it is still a failure. | |
| 1216 | + // | |
| 1217 | + // Scoped to connection details and account identifiers. A new backend | |
| 1218 | + // or provider setting must be added here consciously — see the | |
| 1219 | + // schema-coverage test that walks every registered module and fails | |
| 1220 | + // on an unreviewed key. | |
| 1221 | + $identifiers = array( | |
| 1222 | + // Object-cache backends. | |
| 1223 | + 'redis_host', | |
| 1224 | + 'redis_port', | |
| 1225 | + 'redis_user', | |
| 1226 | + 'redis_socket', | |
| 1227 | + 'redis_database', | |
| 1228 | + 'memcached_host', | |
| 1229 | + 'memcached_port', | |
| 1230 | + 'memcached_user', | |
| 1231 | + 'key_prefix', | |
| 1232 | + // Cloudflare. The same reasoning that withholds redis_user / | |
| 1233 | + // redis_host applies at least as strongly here: an account email | |
| 1234 | + // plus a full Zone ID together identify the account and the exact | |
| 1235 | + // zone. api_token / api_key are already covered by | |
| 1236 | + // is_secret_key(); these two were the gap. | |
| 1237 | + 'email', | |
| 1238 | + 'zone_id', | |
| 1239 | + ); | |
| 1240 | + | |
| 1241 | + /** | |
| 1242 | + * Setting keys whose value is withheld from the activity feed. | |
| 1243 | + * | |
| 1244 | + * @param string[] $identifiers Keys to redact, on top of is_secret_key(). | |
| 1245 | + */ | |
| 1246 | + $identifiers = (array) apply_filters( 'xspeed_activity_redacted_keys', $identifiers ); | |
| 1247 | + | |
| 1248 | + return in_array( strtolower( $key ), array_map( 'strtolower', $identifiers ), true ); | |
| 1249 | + } | |
| 1250 | + | |
| 1251 | + /** | |
| 1252 | + * Whether a schema field holds credential material. A field is secret when | |
| 1253 | + * it declares `type => 'secret'` (the explicit, preferred marker) OR its key | |
| 1254 | + * name matches the credential pattern (is_secret_key) — the backstop that | |
| 1255 | + * catches a credential a module author forgot to type, so a leak can't open | |
| 1256 | + * just because a field was declared `string`. | |
| 1257 | + * | |
| 1258 | + * @param string $key Schema field key. | |
| 1259 | + * @param array $spec Field spec from settings_schema(). | |
| 1260 | + */ | |
| 1261 | + public static function is_secret_field( string $key, array $spec ): bool { | |
| 1262 | + return ( ( $spec['type'] ?? '' ) === 'secret' ) || self::is_secret_key( $key ); | |
| 1263 | + } | |
| 1264 | + | |
| 1265 | + /** | |
| 1266 | + * The subset of $input keys that are secret fields for this module's schema. | |
| 1267 | + * Used by the MCP update_settings tool to name exactly which fields it | |
| 1268 | + * refused. Returns [] for an unknown module. | |
| 1269 | + * | |
| 1270 | + * @param string $slug Module slug. | |
| 1271 | + * @param array<string,mixed> $input Proposed settings patch. | |
| 1272 | + * @return string[] | |
| 1273 | + */ | |
| 1274 | + public static function secret_keys_in( string $slug, array $input ): array { | |
| 1275 | + $module = Module_Registry::get( $slug ); | |
| 1276 | + if ( ! $module ) { | |
| 1277 | + return array(); | |
| 1278 | + } | |
| 1279 | + $schema = $module->settings_schema(); | |
| 1280 | + $out = array(); | |
| 1281 | + foreach ( $input as $key => $value ) { | |
| 1282 | + if ( isset( $schema[ $key ] ) && self::is_secret_field( $key, $schema[ $key ] ) ) { | |
| 1283 | + $out[] = $key; | |
| 1284 | + } | |
| 1285 | + } | |
| 1286 | + return $out; | |
| 1287 | + } | |
| 1288 | + | |
| 1289 | + /** | |
| 1290 | + * Classify an input payload against a module's schema WITHOUT writing | |
| 1291 | + * anything: which keys would be applied, which are unknown, and which are | |
| 1292 | + * in-schema but carry a value the validator rejects. | |
| 1293 | + * | |
| 1294 | + * update() walks the SCHEMA rather than the input, so a key with no schema | |
| 1295 | + * entry is never iterated — never written, never mentioned. And an | |
| 1296 | + * in-schema key whose value fails validation is dropped deliberately | |
| 1297 | + * ("REST layer can add its own strict-mode validation"), which CLI and MCP | |
| 1298 | + * never traverse. Both therefore reported success over a write that did | |
| 1299 | + * not happen; the realistic case is an agent sending | |
| 1300 | + * `cache_enabled` to the `cache` module — a no-op reported as done. (#206) | |
| 1301 | + * | |
| 1302 | + * Pure: no side effects, so callers can decide to refuse BEFORE writing. | |
| 1303 | + * update()'s own signature is deliberately unchanged — a dozen callers | |
| 1304 | + * depend on it returning the settings array. | |
| 1305 | + * | |
| 1306 | + * @param string $slug Module slug. | |
| 1307 | + * @param array<string,mixed> $input Proposed values. | |
| 1308 | + * @return array{applied:string[],unknown:string[],invalid:string[]} | |
| 1309 | + */ | |
| 1310 | + public static function inspect_input( string $slug, array $input ): array { | |
| 1311 | + $out = array( | |
| 1312 | + 'applied' => array(), | |
| 1313 | + 'unknown' => array(), | |
| 1314 | + 'invalid' => array(), | |
| 1315 | + // Keys a wp-config.php constant pins, so update() will drop them. | |
| 1316 | + // Reported here rather than only in the REST/CLI guards, because | |
| 1317 | + // every other caller -- the MCP update_settings tool above all -- | |
| 1318 | + // reaches update() directly and would otherwise report a success | |
| 1319 | + // over a write that changed nothing. (#398) | |
| 1320 | + 'locked' => array(), | |
| 1321 | + ); | |
| 1322 | + | |
| 1323 | + $module = Module_Registry::get( $slug ); | |
| 1324 | + if ( ! $module ) { | |
| 1325 | + // Unknown module: the caller reports that separately, and every key | |
| 1326 | + // is by definition unapplied. | |
| 1327 | + $out['unknown'] = array_keys( $input ); | |
| 1328 | + return $out; | |
| 1329 | + } | |
| 1330 | + | |
| 1331 | + $schema = $module->settings_schema(); | |
| 1332 | + $preserved = $module->preserved_keys(); | |
| 1333 | + | |
| 1334 | + foreach ( $input as $key => $value ) { | |
| 1335 | + // Out-of-schema keys a module explicitly preserves are written | |
| 1336 | + // verbatim by update(), so they count as applied, not unknown. | |
| 1337 | + if ( in_array( $key, $preserved, true ) ) { | |
| 1338 | + $out['applied'][] = $key; | |
| 1339 | + continue; | |
| 1340 | + } | |
| 1341 | + if ( ! isset( $schema[ $key ] ) ) { | |
| 1342 | + $out['unknown'][] = $key; | |
| 1343 | + continue; | |
| 1344 | + } | |
| 1345 | + $spec = $schema[ $key ]; | |
| 1346 | + // A masked secret echo is a deliberate "keep what's stored", not a | |
| 1347 | + // failed write — update() skips it by design, so don't report it. | |
| 1348 | + if ( self::is_secret_field( $key, $spec ) && self::is_masked_secret( (string) $value ) ) { | |
| 1349 | + $out['applied'][] = $key; | |
| 1350 | + continue; | |
| 1351 | + } | |
| 1352 | + [ $coerced, $valid ] = self::validate_field( $value, $spec ); | |
| 1353 | + // Pinned by a constant. An echo of the value already in effect asks | |
| 1354 | + // for no change, so it is not reported -- only a genuine attempt to | |
| 1355 | + // set something different. | |
| 1356 | + if ( null !== self::write_blocking_constant( $slug, $key, $spec ) ) { | |
| 1357 | + $resolved = self::get( $slug ); | |
| 1358 | + if ( $coerced !== ( $resolved[ $key ] ?? null ) ) { | |
| 1359 | + $out['locked'][] = $key; | |
| 1360 | + continue; | |
| 1361 | + } | |
| 1362 | + $out['applied'][] = $key; | |
| 1363 | + continue; | |
| 1364 | + } | |
| 1365 | + if ( $valid ) { | |
| 1366 | + $out['applied'][] = $key; | |
| 1367 | + } else { | |
| 1368 | + $out['invalid'][] = $key; | |
| 1369 | + } | |
| 1370 | + } | |
| 1371 | + | |
| 1372 | + return $out; | |
| 1373 | + } | |
| 1374 | + | |
| 1375 | + /** | |
| 1376 | + * Where a key the caller asked for actually lives, when it isn't in the | |
| 1377 | + * module's schema. Turns "unknown key" into a pointer. | |
| 1378 | + * | |
| 1379 | + * `cache_enabled` is the case worth naming: it is deliberately outside the | |
| 1380 | + * cache module's schema because it drives the drop-in install, so the most | |
| 1381 | + * natural command an agent issues to turn caching on is a silent no-op. | |
| 1382 | + * (#206) | |
| 1383 | + * | |
| 1384 | + * @param string $key Rejected input key. | |
| 1385 | + * @return string Human-readable hint, or '' when there's nothing useful. | |
| 1386 | + */ | |
| 1387 | + public static function hint_for_unknown_key( string $key ): string { | |
| 1388 | + $hints = array( | |
| 1389 | + 'cache_enabled' => 'page caching is not a module setting — it installs the drop-in. Use the dashboard toggle, the REST route /xspeed/v1/cache/toggle, or the MCP `toggle_cache` tool.', | |
| 1390 | + 'gzip_enabled' => 'this moved to the `gzip` module — try `--values=\'{"enabled":true}\'` against module `gzip`.', | |
| 1391 | + ); | |
| 1392 | + return $hints[ $key ] ?? ''; | |
| 1393 | + } | |
| 1394 | + | |
| 1395 | + /** | |
| 1396 | + * Schema keys closest to a rejected key, so a typo gets a pointer rather | |
| 1397 | + * than a bare refusal. Levenshtein over the schema, nearest three. (#206) | |
| 1398 | + * | |
| 1399 | + * @param string $slug Module slug. | |
| 1400 | + * @param string $key Rejected input key. | |
| 1401 | + * @return string[] Suggested key names, nearest first. | |
| 1402 | + */ | |
| 1403 | + public static function did_you_mean( string $slug, string $key ): array { | |
| 1404 | + $module = Module_Registry::get( $slug ); | |
| 1405 | + if ( ! $module ) { | |
| 1406 | + return array(); | |
| 1407 | + } | |
| 1408 | + $scored = array(); | |
| 1409 | + foreach ( array_keys( $module->settings_schema() ) as $candidate ) { | |
| 1410 | + $distance = levenshtein( $key, (string) $candidate ); | |
| 1411 | + // Only near-misses: beyond a third of the key's length it's a | |
| 1412 | + // different word, and listing it would be noise. | |
| 1413 | + if ( $distance <= max( 3, (int) floor( strlen( $key ) / 3 ) ) ) { | |
| 1414 | + $scored[ (string) $candidate ] = $distance; | |
| 1415 | + } | |
| 1416 | + } | |
| 1417 | + asort( $scored ); | |
| 1418 | + return array_slice( array_keys( $scored ), 0, 3 ); | |
| 1419 | + } | |
| 1420 | + | |
| 1421 | + /** | |
| 1422 | + * Masked hint for a stored secret: first 4 + bullets + last 4 (mirrors the | |
| 1423 | + * support-snapshot license masking), or all-bullets for a short secret, or | |
| 1424 | + * '' when unset. Enough to confirm "a key is saved, ending 4f2a" without | |
| 1425 | + * disclosing it. Deterministic — unit-tested. | |
| 1426 | + */ | |
| 1427 | + public static function mask_secret_value( string $value ): string { | |
| 1428 | + if ( '' === $value ) { | |
| 1429 | + return ''; | |
| 1430 | + } | |
| 1431 | + if ( strlen( $value ) <= 8 ) { | |
| 1432 | + return str_repeat( '•', 8 ); | |
| 1433 | + } | |
| 1434 | + return substr( $value, 0, 4 ) . self::SECRET_MASK_BULLETS . substr( $value, -4 ); | |
| 1435 | + } | |
| 1436 | + | |
| 1437 | + /** | |
| 1438 | + * Whether an incoming write value is the masked placeholder the client is | |
| 1439 | + * echoing back, rather than a real new secret — i.e. it still carries the | |
| 1440 | + * mask bullets. A genuine credential never contains the bullet run, so this | |
| 1441 | + * can't swallow a real key. update() uses it to keep the stored secret. | |
| 1442 | + * | |
| 1443 | + * An EMPTY string is NOT a mask echo — it's a deliberate clear, so it flows | |
| 1444 | + * through to storage and removes the credential. The dashboard always | |
| 1445 | + * re-sends the masked hint (with bullets) on an unrelated save, never an | |
| 1446 | + * empty string, so this still can't wipe a key by accident. (#115, QA B7) | |
| 1447 | + */ | |
| 1448 | + public static function is_masked_secret( string $value ): bool { | |
| 1449 | + return false !== strpos( $value, self::SECRET_MASK_BULLETS ); | |
| 1450 | + } | |
| 1451 | + | |
| 1452 | + /** | |
| 1453 | + * Encrypt a plaintext secret for storage. Idempotent: an already-encrypted | |
| 1454 | + * value (carrying the marker) is returned unchanged, so module migrations | |
| 1455 | + * can call this over existing rows without double-wrapping. Empty stays | |
| 1456 | + * empty. Used by update() and by the per-module encrypt-on-upgrade | |
| 1457 | + * migrations. (#115) | |
| 1458 | + */ | |
| 1459 | + public static function encrypt_for_storage( string $value ): string { | |
| 1460 | + if ( '' === $value || 0 === strpos( $value, self::SECRET_CIPHER_PREFIX ) ) { | |
| 1461 | + return $value; | |
| 1462 | + } | |
| 1463 | + return self::encrypt( $value ); | |
| 1464 | + } | |
| 1465 | + | |
| 1466 | + /** | |
| 1467 | + * 32-byte encryption key derived from this site's WordPress salts, so the | |
| 1468 | + * ciphertext is bound to the install and never stored alongside the data. | |
| 1469 | + * Rotating the salts makes existing secrets undecryptable — decrypt() then | |
| 1470 | + * returns '' (treated as "unset", the user re-enters the key) rather than | |
| 1471 | + * fataling. Uses AUTH_KEY + SECURE_AUTH_SALT, falling back to wp_salt(). | |
| 1472 | + */ | |
| 1473 | + private static function secret_key(): string { | |
| 1474 | + $material = ''; | |
| 1475 | + if ( defined( 'AUTH_KEY' ) ) { | |
| 1476 | + $material .= (string) AUTH_KEY; | |
| 1477 | + } | |
| 1478 | + if ( defined( 'SECURE_AUTH_SALT' ) ) { | |
| 1479 | + $material .= (string) SECURE_AUTH_SALT; | |
| 1480 | + } | |
| 1481 | + if ( '' === $material && function_exists( 'wp_salt' ) ) { | |
| 1482 | + $material = (string) wp_salt( 'secure_auth' ); | |
| 1483 | + } | |
| 1484 | + return sodium_crypto_generichash( 'xspeed-secret-v1|' . $material, '', SODIUM_CRYPTO_SECRETBOX_KEYBYTES ); | |
| 1485 | + } | |
| 1486 | + | |
| 1487 | + /** | |
| 1488 | + * Authenticated-encrypt a non-empty plaintext with libsodium's secretbox | |
| 1489 | + * (XSalsa20-Poly1305). The random nonce is prepended to the ciphertext and | |
| 1490 | + * the whole thing base64'd behind the version marker. libsodium ships in | |
| 1491 | + * PHP core from 7.2 (our floor is 7.4); if it were somehow unavailable we | |
| 1492 | + * store plaintext rather than fatal — masking on read still applies. | |
| 1493 | + */ | |
| 1494 | + private static function encrypt( string $plain ): string { | |
| 1495 | + if ( ! function_exists( 'sodium_crypto_secretbox' ) ) { | |
| 1496 | + return $plain; | |
| 1497 | + } | |
| 1498 | + try { | |
| 1499 | + $nonce = random_bytes( SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); | |
| 1500 | + $cipher = sodium_crypto_secretbox( $plain, $nonce, self::secret_key() ); | |
| 1501 | + } catch ( \Throwable $e ) { | |
| 1502 | + return $plain; | |
| 1503 | + } | |
| 1504 | + return self::SECRET_CIPHER_PREFIX . base64_encode( $nonce . $cipher ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- transport encoding for ciphertext, not obfuscation. | |
| 1505 | + } | |
| 1506 | + | |
| 1507 | + /** | |
| 1508 | + * Reverse encrypt(). A value without the marker is legacy plaintext (or | |
| 1509 | + * empty) and is returned as-is — the encryption rollout is lazy, so reads | |
| 1510 | + * keep working before the first re-save. A marked value that fails to | |
| 1511 | + * decrypt (salts rotated, row tampered) returns '' so the caller behaves as | |
| 1512 | + * "no credential set", never a fatal. | |
| 1513 | + */ | |
| 1514 | + private static function decrypt( string $stored ): string { | |
| 1515 | + if ( 0 !== strpos( $stored, self::SECRET_CIPHER_PREFIX ) ) { | |
| 1516 | + return $stored; | |
| 1517 | + } | |
| 1518 | + if ( ! function_exists( 'sodium_crypto_secretbox_open' ) ) { | |
| 1519 | + return ''; | |
| 1520 | + } | |
| 1521 | + $raw = base64_decode( substr( $stored, strlen( self::SECRET_CIPHER_PREFIX ) ), true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding our own ciphertext envelope. | |
| 1522 | + if ( false === $raw || strlen( $raw ) <= SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ) { | |
| 1523 | + return ''; | |
| 1524 | + } | |
| 1525 | + $nonce = substr( $raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); | |
| 1526 | + $cipher = substr( $raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES ); | |
| 1527 | + try { | |
| 1528 | + $plain = sodium_crypto_secretbox_open( $cipher, $nonce, self::secret_key() ); | |
| 1529 | + } catch ( \Throwable $e ) { | |
| 1530 | + return ''; | |
| 1531 | + } | |
| 1532 | + return ( false === $plain ) ? '' : $plain; | |
| 1533 | + } | |
| 1534 | + | |
| 1535 | + /** | |
| 1536 | + * Whether the current write is an MCP write that may NOT touch secret | |
| 1537 | + * fields — i.e. it came in over MCP and the connection lacks the `configure` | |
| 1538 | + * grant. Keeps credential writes off the default MCP surface (#116). Guarded | |
| 1539 | + * by class_exists so Settings_Manager never hard-depends on the MCP module. | |
| 1540 | + */ | |
| 1541 | + private static function mcp_write_blocked(): bool { | |
| 1542 | + if ( ! class_exists( '\\XSpeed\\Modules\\Mcp\\Mcp_Tools' ) ) { | |
| 1543 | + return false; | |
| 1544 | + } | |
| 1545 | + return \XSpeed\Modules\Mcp\Mcp_Tools::in_dispatch() | |
| 1546 | + && ! \XSpeed\Modules\Mcp\Mcp_Tools::can_configure(); | |
| 1547 | + } | |
| 1548 | + | |
| 173 | 1549 | /** Compact human form of a setting value for the change log. */ |
| 174 | 1550 | private static function describe_value( $value ): string { |
| 175 | 1551 | if ( is_bool( $value ) ) { |
| 176 | 1552 | return $value ? 'on' : 'off'; |
| @@ -278,9 +1654,21 @@ | ||
| 278 | 1654 | return $spec['default'] ?? array(); |
| 279 | 1655 | } |
| 280 | 1656 | return array_values( array_filter( $value, 'is_scalar' ) ); |
| 281 | 1657 | case 'url': |
| 1658 | + // A deliberately-cleared URL must read back as empty, not snap | |
| 1659 | + // to the schema default — `?:` swallowed the empty string and | |
| 1660 | + // resurrected the default on every read. (#197) | |
| 1661 | + if ( '' === trim( (string) $value ) ) { | |
| 1662 | + return ''; | |
| 1663 | + } | |
| 282 | 1664 | $url = esc_url_raw( (string) $value ); |
| 1665 | + if ( ! empty( $spec['endpoint'] ) && ! self::is_endpoint_url( $url ) ) { | |
| 1666 | + // A hand-edited or pre-validation stored value that can't | |
| 1667 | + // be called reads back as empty, so consumers see "no | |
| 1668 | + // endpoint configured" instead of silently failing on it. | |
| 1669 | + return $spec['default'] ?? ''; | |
| 1670 | + } | |
| 283 | 1671 | return $url ?: ( $spec['default'] ?? '' ); |
| 284 | 1672 | case 'media': |
| 285 | 1673 | // Media-library image URL. Empty is a valid "no image" state. |
| 286 | 1674 | // esc_url_raw alone lets through any safe URL (…/evil.txt, |
| @@ -287,8 +1675,16 @@ | ||
| 287 | 1675 | // non-images) which then renders as a broken <img>; require it |
| 288 | 1676 | // to look like an image and drop anything else to empty. |
| 289 | 1677 | $media = esc_url_raw( (string) $value ); |
| 290 | 1678 | return ( '' === $media || self::is_image_url( $media ) ) ? $media : ''; |
| 1679 | + case 'secret': | |
| 1680 | + // A credential (API token, password, …). Stored encrypted at | |
| 1681 | + // rest (SECRET_CIPHER_PREFIX). Reading decrypts to plaintext so | |
| 1682 | + // the engine — Cloudflare purge, Redis auth — gets the real | |
| 1683 | + // value; the masking that keeps it out of REST/MCP/dashboard | |
| 1684 | + // payloads happens later, at the output boundary (get_public), | |
| 1685 | + // never here. Legacy unencrypted values pass straight through. | |
| 1686 | + return self::decrypt( (string) $value ); | |
| 291 | 1687 | case 'string': |
| 292 | 1688 | default: |
| 293 | 1689 | return sanitize_text_field( (string) $value ); |
| 294 | 1690 | } |
| @@ -356,9 +1752,19 @@ | ||
| 356 | 1752 | } |
| 357 | 1753 | } |
| 358 | 1754 | return array( $out, true ); |
| 359 | 1755 | case 'url': |
| 1756 | + // Empty is a valid "cleared" state, not invalid input — same | |
| 1757 | + // as `media` below. Reporting it invalid made the previous | |
| 1758 | + // stored value stick, so clearing a URL field appeared to | |
| 1759 | + // "come back" a moment later when the save echo landed. (#197) | |
| 1760 | + if ( '' === trim( (string) $value ) ) { | |
| 1761 | + return array( '', true ); | |
| 1762 | + } | |
| 360 | 1763 | $u = esc_url_raw( (string) $value ); |
| 1764 | + if ( ! empty( $spec['endpoint'] ) && ! self::is_endpoint_url( $u ) ) { | |
| 1765 | + return array( null, false ); | |
| 1766 | + } | |
| 361 | 1767 | return array( $u, (bool) $u ); |
| 362 | 1768 | case 'media': |
| 363 | 1769 | // Empty (cleared logo) is valid; any non-empty value must be a |
| 364 | 1770 | // safe URL after esc_url_raw AND look like an image, so a |
| @@ -369,12 +1775,53 @@ | ||
| 369 | 1775 | return array( '', true ); |
| 370 | 1776 | } |
| 371 | 1777 | $ok = '' !== $m && self::is_image_url( $m ); |
| 372 | 1778 | return array( $ok ? $m : '', $ok ); |
| 1779 | + case 'secret': | |
| 1780 | + // Validated like a string; encryption is applied uniformly in | |
| 1781 | + // update() after this returns, so a secret carried over from the | |
| 1782 | + // current stored value gets encrypted the same way a freshly | |
| 1783 | + // entered one does. Masked placeholders never reach here — update() | |
| 1784 | + // filters them out before validating. (#115) | |
| 1785 | + return array( sanitize_text_field( (string) $value ), true ); | |
| 373 | 1786 | case 'string': |
| 374 | 1787 | default: |
| 375 | 1788 | return array( sanitize_text_field( (string) $value ), true ); |
| 376 | 1789 | } |
| 1790 | + } | |
| 1791 | + | |
| 1792 | + /** | |
| 1793 | + * Whether a URL is something an HTTP client could actually call. | |
| 1794 | + * | |
| 1795 | + * `url` fields flagged `endpoint => true` in the schema hold URLs the | |
| 1796 | + * plugin will POST to (a critical-CSS generator, an unused-CSS | |
| 1797 | + * generator). esc_url_raw() alone is not enough for those: | |
| 1798 | + * `nahid@wpdeveloper.com` — an email address pasted into the field — | |
| 1799 | + * comes back as `http://nahid@wpdeveloper.com`, a syntactically valid | |
| 1800 | + * URL whose userinfo is the whole address, and the feature then fails | |
| 1801 | + * silently for as long as nobody rereads the field. Require a real | |
| 1802 | + * http(s) scheme and a host, and refuse userinfo outright: no | |
| 1803 | + * endpoint of ours authenticates that way, and accepting it is how | |
| 1804 | + * that address survived in production. (xspeed-pro#77) | |
| 1805 | + */ | |
| 1806 | + public static function is_endpoint_url( string $url ): bool { | |
| 1807 | + if ( '' === $url ) { | |
| 1808 | + return false; | |
| 1809 | + } | |
| 1810 | + $parts = wp_parse_url( $url ); | |
| 1811 | + if ( ! is_array( $parts ) ) { | |
| 1812 | + return false; | |
| 1813 | + } | |
| 1814 | + if ( ! in_array( $parts['scheme'] ?? '', array( 'http', 'https' ), true ) ) { | |
| 1815 | + return false; | |
| 1816 | + } | |
| 1817 | + if ( '' === (string) ( $parts['host'] ?? '' ) ) { | |
| 1818 | + return false; | |
| 1819 | + } | |
| 1820 | + if ( isset( $parts['user'] ) || isset( $parts['pass'] ) ) { | |
| 1821 | + return false; | |
| 1822 | + } | |
| 1823 | + return true; | |
| 377 | 1824 | } |
| 378 | 1825 | |
| 379 | 1826 | /** |
| 380 | 1827 | * Whether a URL looks like an image — used to gate `media` fields so a |