PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 All 36 releases
← All changes | includes/class-migration.php +1020 -72 1.0.5 → 1.4.2 View file →
@@ -62,10 +62,21 @@
62 62
63 63 /** Site option holding the list of source ids already imported. */
64 64 private const IMPORTED_OPTION = 'xspeed_migration_imported';
65 65
66 - /** Source id → plugin file, for the active-state check. */
67 - private const PLUGIN_FILE = array(
66 + /**
67 + * Source id → plugin file. The ONE home for this map.
68 + *
69 + * Public because MigrationModule needs the same mapping to deactivate a
70 + * source, and it used to keep a private copy. The two were identical, but
71 + * a drift would have been silent and destructive: this map drives the
72 + * `active` flag that decides whether the panel shows its confirmation, and
73 + * the module's copy drove the actual deactivation. Diverge them and
74 + * status() reports active:false, the panel skips the confirm, and the
75 + * plugin is deactivated anyway — a genuinely silent deactivation, one edit
76 + * away. (#189)
77 + */
78 + public const PLUGIN_FILE = array(
68 79 'wp-rocket' => 'wp-rocket/wp-rocket.php',
69 80 'w3-total-cache' => 'w3-total-cache/w3-total-cache.php',
70 81 'wp-super-cache' => 'wp-super-cache/wp-cache.php',
71 82 'litespeed-cache' => 'litespeed-cache/litespeed-cache.php',
@@ -70,8 +81,100 @@
70 81 'wp-super-cache' => 'wp-super-cache/wp-cache.php',
71 82 'litespeed-cache' => 'litespeed-cache/litespeed-cache.php',
72 83 );
73 84
85 + /** The plugin file for a source id, or '' when the id is unknown. */
86 + public static function plugin_file( string $source ): string {
87 + return self::PLUGIN_FILE[ $source ] ?? '';
88 + }
89 +
90 + /**
91 + * Site option recording a source that was imported but LEFT RUNNING.
92 + *
93 + * Holds `{ id, label }` for the last such import, or is absent. Not a
94 + * history — the risk is "a second page cache is live right now", which is
95 + * a single present-tense fact, not a log. (#189 AC4)
96 + */
97 + private const ACTIVE_SOURCE_OPTION = 'xspeed_migration_source_active';
98 +
99 + /**
100 + * Remember that an import finished with the source plugin still on, so the
101 + * warning can outlive the import screen.
102 + *
103 + * Self-clearing rather than sticky: it stores only while the plugin is
104 + * genuinely still active, and drops the record as soon as it is not. A
105 + * user who deactivates the old plugin by hand from the Plugins screen
106 + * never told us — so a stored flag we only cleared on OUR own deactivation
107 + * path would nag forever about a plugin that is already off.
108 + *
109 + * @param string $source Source id.
110 + * @param string $label Human label for the source.
111 + */
112 + public static function remember_active_source( string $source, string $label ): void {
113 + $file = self::plugin_file( $source );
114 + if ( '' === $file ) {
115 + return;
116 + }
117 +
118 + require_once ABSPATH . 'wp-admin/includes/plugin.php';
119 + if ( ! is_plugin_active( $file ) ) {
120 + self::forget_active_source( $source );
121 + return;
122 + }
123 +
124 + update_option(
125 + self::ACTIVE_SOURCE_OPTION,
126 + array(
127 + 'id' => $source,
128 + 'label' => '' !== $label ? $label : $source,
129 + )
130 + );
131 + }
132 +
133 + /** Drop the record — the source is off, or was never on. */
134 + public static function forget_active_source( string $source = '' ): void {
135 + if ( '' !== $source ) {
136 + $stored = get_option( self::ACTIVE_SOURCE_OPTION, array() );
137 + if ( is_array( $stored ) && ( $stored['id'] ?? '' ) !== $source ) {
138 + return;
139 + }
140 + }
141 + delete_option( self::ACTIVE_SOURCE_OPTION );
142 + }
143 +
144 + /**
145 + * The imported-but-still-running source, or null.
146 + *
147 + * Re-checks the plugin's live state on every read, so the warning
148 + * disappears by itself the moment the user deactivates the plugin —
149 + * whether they did it through us or from the Plugins screen.
150 + *
151 + * @return array{id:string,label:string}|null
152 + */
153 + public static function pending_source(): ?array {
154 + $stored = get_option( self::ACTIVE_SOURCE_OPTION, array() );
155 + if ( ! is_array( $stored ) || empty( $stored['id'] ) ) {
156 + return null;
157 + }
158 +
159 + $file = self::plugin_file( (string) $stored['id'] );
160 + if ( '' === $file ) {
161 + return null;
162 + }
163 +
164 + require_once ABSPATH . 'wp-admin/includes/plugin.php';
165 + if ( ! is_plugin_active( $file ) ) {
166 + // Resolved itself — stop warning, and stop re-checking.
167 + delete_option( self::ACTIVE_SOURCE_OPTION );
168 + return null;
169 + }
170 +
171 + return array(
172 + 'id' => (string) $stored['id'],
173 + 'label' => (string) ( $stored['label'] ?? $stored['id'] ),
174 + );
175 + }
176 +
74 177 /** Record a source as imported (idempotent). */
75 178 public static function mark_imported( string $id ): void {
76 179 $done = (array) get_option( self::IMPORTED_OPTION, array() );
77 180 if ( ! in_array( $id, $done, true ) ) {
@@ -104,13 +207,9 @@
104 207 $mapped = 0;
105 208 if ( $detected ) {
106 209 $patch = call_user_func( $spec['plan'], $raw );
107 210 if ( is_array( $patch ) ) {
108 - foreach ( $patch as $vals ) {
109 - if ( is_array( $vals ) ) {
110 - $mapped += count( $vals );
111 - }
112 - }
211 + $mapped = self::count_meaningful( $patch );
113 212 }
114 213 }
115 214 $file = self::PLUGIN_FILE[ $id ] ?? '';
116 215 $out[] = array(
@@ -126,11 +225,238 @@
126 225 return $out;
127 226 }
128 227
129 228 /**
229 + * Count the settings in a plan patch that will MEANINGFULLY change the
230 + * config — i.e. the ones actually enabled / non-empty in the source.
231 + *
232 + * A `plan_*` patch always emits every mapped field, including the ones the
233 + * source has turned OFF (`false`) or left empty. Counting those inflated
234 + * the "settings available to migrate" number — a source with 2 settings on
235 + * still reported ~12 because the importer listed every false mapping.
236 + * Disabled (`false`) booleans and empty arrays/strings/zeros contribute
237 + * nothing on import, so they're excluded from the count. (FBS-82449)
238 + *
239 + * @param array<string,mixed> $patch Plan patch (module slug => values).
240 + * @return int Number of enabled / non-empty settings.
241 + */
242 + private static function count_meaningful( array $patch ): int {
243 + $count = 0;
244 + foreach ( $patch as $vals ) {
245 + if ( is_array( $vals ) ) {
246 + $count += count( self::meaningful_values( $vals ) );
247 + }
248 + }
249 + return $count;
250 + }
251 +
252 + /**
253 + * Filter one module's plan values down to the ones that meaningfully change
254 + * the config: enabled (`true`) booleans, non-empty arrays, and non-empty
255 + * scalars. Disabled toggles, empty lists, and zero/empty scalars are
256 + * dropped — they represent "nothing to import" for that setting. Shared by
257 + * the count (status) and the write (apply) so both agree. (FBS-82449)
258 + *
259 + * @param array<string,mixed> $values One module's mapped values.
260 + * @return array<string,mixed> Only the meaningful entries.
261 + */
262 + private static function meaningful_values( array $values ): array {
263 + $out = array();
264 + foreach ( $values as $key => $value ) {
265 + if ( is_bool( $value ) ) {
266 + if ( $value ) {
267 + $out[ $key ] = $value; // Only an enabled toggle imports.
268 + }
269 + } elseif ( is_array( $value ) ) {
270 + if ( ! empty( $value ) ) {
271 + $out[ $key ] = $value; // Non-empty list (e.g. excluded_urls).
272 + }
273 + } elseif ( '' !== $value && null !== $value && 0 !== $value && '0' !== $value ) {
274 + $out[ $key ] = $value; // Non-empty scalar (e.g. cache_expiry).
275 + }
276 + // NOTE: a scalar 0 is deliberately dropped — for the numeric
277 + // settings we import (cache_expiry, timeouts, db index) it means
278 + // "unset" in every source, and emitting it would put a
279 + // meaningless value in the plan the user is asked to confirm.
280 + // The trap: this also swallows a legitimate 0 from a TRI-STATE
281 + // key. WP Super Cache's `wp_cache_not_logged_in` is 0/1/2, where
282 + // 0 means "cache everyone" — a real choice. read_wpsc_config_file()
283 + // already preserves it as an int, but any future mapping of that
284 + // key must bypass this helper or the user's choice is discarded
285 + // silently. (#222 F4)
286 + }
287 + return $out;
288 + }
289 +
290 + /**
291 + * Convert a source plugin's seconds-based lifetime to xSpeed's
292 + * hour-granular `cache_expiry`, rounding to nearest within our 1–720
293 + * bounds. See the call site for why flooring was wrong. (#218)
294 + */
295 + private static function seconds_to_hours( int $seconds ): int {
296 + if ( $seconds <= 0 ) {
297 + return 24;
298 + }
299 + return (int) max( 1, min( 720, (int) round( $seconds / 3600 ) ) );
300 + }
301 +
302 + /**
303 + * Map a source plugin's preload interval (seconds) onto xSpeed's
304 + * schedule enum. Anything under a day rounds to `hourly` — our finest
305 + * grain — rather than being dropped for not matching exactly. (#218)
306 + */
307 + private static function seconds_to_schedule( int $seconds ): string {
308 + if ( $seconds <= 0 ) {
309 + return 'manual';
310 + }
311 + // Literal seconds rather than WEEK_IN_SECONDS / DAY_IN_SECONDS: this
312 + // planner is a pure function and is unit-tested without a WP bootstrap.
313 + if ( $seconds >= 604800 ) {
314 + return 'weekly';
315 + }
316 + if ( $seconds >= 86400 ) {
317 + return 'daily';
318 + }
319 + return 'hourly';
320 + }
321 +
322 + /**
323 + * Human-readable notes about values this import cannot carry across
324 + * exactly, for the preview panel. Empty when everything maps cleanly.
325 + *
326 + * A silent lossy conversion is the thing the user cannot audit: a
327 + * 15-minute page-cache lifetime arriving as 1 hour looks deliberate.
328 + * (#218)
329 + *
330 + * @param string $source_id Source plugin id.
331 + * @param array $raw Source plugin's own config.
332 + * @return string[] One note per lossy conversion.
333 + */
334 + public static function preview_notes( string $source_id, array $raw ): array {
335 + $notes = array();
336 +
337 + if ( 'w3-total-cache' === $source_id && isset( $raw['pgcache.lifetime'] ) ) {
338 + $seconds = (int) $raw['pgcache.lifetime'];
339 + $hours = self::seconds_to_hours( $seconds );
340 + if ( $seconds > 0 && $seconds !== $hours * 3600 ) {
341 + $notes[] = sprintf(
342 + /* translators: 1: source lifetime in seconds, 2: imported lifetime in hours. */
343 + __( 'Page cache lifetime %1$ds cannot be expressed in whole hours — importing as %2$dh.', 'xspeed' ),
344 + $seconds,
345 + $hours
346 + );
347 + }
348 + }
349 +
350 + // A value we deliberately DROP needs saying too. Rounding a lifetime
351 + // was announced while a skipped secret was not, and skipping is the
352 + // more consequential of the two: the object cache silently fails to
353 + // connect afterwards and nothing on screen explains why. Only fires
354 + // when the source really has a secret we cannot read — an empty
355 + // password is not a loss. (#218 F3)
356 + if ( 'w3-total-cache' === $source_id ) {
357 + foreach ( array(
358 + 'objectcache.redis.password' => __( 'Redis password', 'xspeed' ),
359 + 'dbcache.redis.password' => __( 'Database-cache Redis password', 'xspeed' ),
360 + 'pgcache.redis.password' => __( 'Page-cache Redis password', 'xspeed' ),
361 + ) as $key => $label ) {
362 + if ( empty( $raw[ $key ] ) || ! is_string( $raw[ $key ] ) ) {
363 + continue;
364 + }
365 + if ( null !== self::decrypt_w3tc_secret( $raw[ $key ] ) ) {
366 + continue; // readable — it will be imported.
367 + }
368 + $notes[] = sprintf(
369 + /* translators: %s: human label for the secret that could not be read. */
370 + __( '%s is encrypted and could not be read — it will not be imported. Enter it again in xSpeed after importing.', 'xspeed' ),
371 + $label
372 + );
373 + }
374 + }
375 +
376 +
377 + // A TLS endpoint imports its host and port, but NOT its transport:
378 + // xSpeed has no TLS option in the object-cache schema and
379 + // Redis_Client::connect() only ever builds "tcp://{$host}:{$port}".
380 + // Keeping the scheme in the host made that failure silent AND
381 + // unconditional — `tcp://tls://cache.internal:6380` fails to resolve
382 + // the literal host "tls" — so the scheme is stripped and the loss is
383 + // announced instead, the same way an unreadable secret is. The import
384 + // then connects wherever plain TCP is also open, and says so where it
385 + // cannot. (#224)
386 + if ( 'w3-total-cache' === $source_id ) {
387 + foreach ( array( 'redis', 'memcached' ) as $engine ) {
388 + $servers = $raw[ 'objectcache.' . $engine . '.servers' ] ?? null;
389 + if ( empty( $servers ) || ! is_array( $servers ) ) {
390 + continue;
391 + }
392 + $first = (string) ( $servers[0] ?? '' );
393 + // A Redis socket imports whole, so there is nothing to warn about.
394 + if ( 'redis' === $engine && '' !== Redis_Client::socket_path( $first ) ) {
395 + continue;
396 + }
397 + if ( ! preg_match( '#^([a-z][a-z0-9+.-]*)://#i', $first, $m ) ) {
398 + continue;
399 + }
400 + $notes[] = sprintf(
401 + /* translators: 1: endpoint scheme, e.g. tls. 2: the endpoint as configured in the source plugin. */
402 + __( 'Object-cache endpoint %2$s uses %1$s, which xSpeed does not support — importing the host and port only. The cache will connect only if the server also accepts a plain connection.', 'xspeed' ),
403 + strtoupper( $m[1] ),
404 + $first
405 + );
406 + }
407 + }
408 + return $notes;
409 + }
410 +
411 + /**
412 + * Map a source plugin's "separate mobile cache" flag onto the cache patch
413 + * WITHOUT enabling xSpeed's mobile_separate. The xSpeed static-file fast
414 + * path is device-blind, so mobile_separate=ON disables it — and a source
415 + * site that had the flag on very often serves identical HTML to every
416 + * device (it was on by habit). Rather than silently kill the fast path on
417 + * import, we keep mobile_separate off and, when the source had it on, set
418 + * `mobile_separate_review` so the dashboard can prompt the user to turn it
419 + * back on only if their site really differs per device.
420 + * (FBS-83144 / FBS-83145)
421 + *
422 + * @param array $patch The plan patch (modified by reference).
423 + * @param bool $source_on Whether the source plugin had mobile-separate on.
424 + */
425 + private static function map_mobile_separate( array &$patch, bool $source_on ): void {
426 + if ( ! isset( $patch['cache'] ) || ! is_array( $patch['cache'] ) ) {
427 + $patch['cache'] = array();
428 + }
429 + // Never import as ON; leave the fast path intact.
430 + $patch['cache']['mobile_separate'] = false;
431 + if ( $source_on ) {
432 + $patch['cache']['mobile_separate_review'] = true;
433 + }
434 + }
435 +
436 + /**
130 437 * Return the patch that `apply()` would write, without writing.
131 438 */
132 439 public static function preview( string $source_id ): ?array {
440 + $full = self::preview_with_notes( $source_id );
441 +
442 + return null === $full ? null : $full['patch'];
443 + }
444 +
445 + /**
446 + * The preview plan together with the notes that explain it.
447 + *
448 + * preview_notes() exists to tell the user when a source value could not
449 + * be carried over exactly — e.g. a 15-minute W3TC lifetime that xSpeed
450 + * can only express in whole hours. It was written but never called, so
451 + * the panel showed the rounded number as plain fact and the user had no
452 + * way to know their setting had changed. Returning both from one detect
453 + * pass keeps them in lockstep. (#224 F2)
454 + *
455 + * @param string $source_id Source plugin id.
456 + * @return array{patch:array,notes:string[]}|null Null when undetected.
457 + */
458 + public static function preview_with_notes( string $source_id ): ?array {
133 459 $src = self::sources()[ $source_id ] ?? null;
134 460 if ( null === $src ) {
135 461 return null;
136 462 }
@@ -137,9 +463,13 @@
137 463 $raw = call_user_func( $src['detect'] );
138 464 if ( ! is_array( $raw ) ) {
139 465 return null;
140 466 }
141 - return call_user_func( $src['plan'], $raw );
467 +
468 + return array(
469 + 'patch' => call_user_func( $src['plan'], $raw ),
470 + 'notes' => self::preview_notes( $source_id, $raw ),
471 + );
142 472 }
143 473
144 474 /**
145 475 * Read source settings + write the translated patch. Returns the
@@ -154,23 +484,289 @@
154 484 foreach ( $patch as $slug => $values ) {
155 485 if ( ! is_string( $slug ) || ! is_array( $values ) ) {
156 486 continue;
157 487 }
158 - $option = 'xspeed_module_' . $slug;
159 - $cur = (array) get_option( $option, array() );
160 - $next = array_merge( $cur, $values );
161 - $ok = update_option( $option, $next );
488 + // Import only the settings that are actually enabled / non-empty in
489 + // the source. A plan patch emits every mapped field including the
490 + // ones the source turned OFF; merging those `false`/empty values
491 + // would silently DISABLE settings the user already had on in xSpeed.
492 + // Migration is additive — it never clobbers existing config with a
493 + // source's disabled value. (FBS-82449)
494 + $meaningful = self::meaningful_values( $values );
495 + if ( empty( $meaningful ) ) {
496 + continue;
497 + }
498 +
499 + // The page-cache switch is NOT a module setting. It lives in the
500 + // `xspeed_options` blob, and turning it on means installing the
501 + // drop-in, setting WP_CACHE and installing the rewrite — work only
502 + // Cache::toggle() does. Writing it to xspeed_module_cache['enabled']
503 + // put it in a key that is not in CacheModule::settings_schema() and
504 + // that nothing reads, so every importer's `cache.enabled` was a
505 + // dead destination: the import reported success and the site came
506 + // out of it with caching still off. (#219)
507 + $applied_enable = null;
508 + if ( 'cache' === $slug && array_key_exists( 'enabled', $meaningful ) ) {
509 + $applied_enable = (bool) $meaningful['enabled'];
510 + unset( $meaningful['enabled'] );
511 + }
512 +
513 + // Union list values with what is already in EFFECT rather than
514 + // overwriting them.
515 + //
516 + // A source's exclusion list is what IT needed; ours covers cases it
517 + // handled with separate settings we don't read (W3TC has
518 + // pgcache.cache.feed, pgcache.reject.request_head, …). Assigning
519 + // the mapped array straight over ours dropped every xSpeed-specific
520 + // safety rule — /wp-json/, /xmlrpc.php, ~wp-.*\.php, /feed/ and
521 + // ~sitemap(_index)?\.xml among them — and feeds and sitemaps
522 + // started being served from the page cache.
523 + //
524 + // Merge against Settings_Manager::get(), not the raw option: module
525 + // defaults live in the SCHEMA, so on a fresh install the stored
526 + // option is empty and a union with it would still lose all of them.
527 + // The class comment above already promised migration is additive;
528 + // before this that only held for scalars. (#218)
529 + $effective = (array) Settings_Manager::get( $slug );
530 + foreach ( $meaningful as $key => $value ) {
531 + if ( ! is_array( $value ) ) {
532 + continue;
533 + }
534 + $existing = $effective[ $key ] ?? array();
535 + if ( ! is_array( $existing ) || empty( $existing ) ) {
536 + continue;
537 + }
538 + $meaningful[ $key ] = array_values( array_unique( array_merge( $existing, $value ) ) );
539 + }
540 +
541 + // Write through Settings_Manager::update(), not a raw
542 + // update_option().
543 + //
544 + // The raw write skipped everything that makes a settings write
545 + // safe: schema coercion, per-field range/type validation, secret
546 + // encryption at rest, `_version` maintenance, and log_changes() —
547 + // so imported values landed unvalidated and no per-module change
548 + // ever reached the activity log, only the single "Imported
549 + // settings from…" line. It also means a bad value from a source
550 + // plugin could be stored where the UI could never have set it.
551 + //
552 + // update() returns the module's public view, which is also the
553 + // honest success signal: it reflects what is actually stored,
554 + // where update_option()'s return value conflates "no change
555 + // needed" with "write failed". (#218)
556 + $stored = Settings_Manager::update( $slug, $meaningful );
557 +
558 + // Did the intent land? Not "is the stored value identical" — for a
559 + // list we deliberately UNION with the existing rules, so the stored
560 + // array is legitimately bigger than what we passed in. Assert each
561 + // imported value is PRESENT instead, which is what "applied" means
562 + // and what a lossy or rejected write would fail.
563 + //
564 + // Secrets come back masked in the public view, so they are taken on
565 + // trust rather than compared against plaintext.
566 + $ok = ! empty( $stored );
567 + foreach ( $meaningful as $key => $value ) {
568 + if ( ! array_key_exists( $key, $stored ) ) {
569 + // A key the module deliberately owns elsewhere (cache.enabled
570 + // lives in xspeed_options and is applied via Cache::toggle)
571 + // is not a failed write.
572 + continue;
573 + }
574 + if ( is_string( $stored[ $key ] ) && Settings_Manager::is_masked_secret( $stored[ $key ] ) ) {
575 + continue;
576 + }
577 + if ( is_array( $value ) ) {
578 + if ( array_diff( $value, (array) $stored[ $key ] ) ) {
579 + $ok = false;
580 + break;
581 + }
582 + continue;
583 + }
584 + if ( $stored[ $key ] != $value ) { // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison -- values round-trip through the DB and schema coercion, so a stored "1" must still count as an applied true.
585 + $ok = false;
586 + break;
587 + }
588 + }
589 +
590 + $applied = array_keys( $meaningful );
591 +
592 + if ( null !== $applied_enable ) {
593 + $state = Cache::toggle( $applied_enable );
594 + $applied[] = 'enabled';
595 + // Enabling is only real if the transaction went through. The
596 + // drop-in alone is not the test: a refusal reports the
597 + // artifacts already on disk, so a site whose xSpeed drop-in
598 + // was still installed read a refused write as success.
599 + if ( ! empty( $state['blocked'] ) ) {
600 + $ok = false;
601 + } elseif ( $applied_enable && empty( $state['dropin_installed'] ) ) {
602 + $ok = false;
603 + }
604 + }
605 +
606 + if ( empty( $applied ) ) {
607 + continue;
608 + }
609 +
162 610 $results[ $slug ] = array(
163 - 'ok' => (bool) $ok,
164 - 'applied' => array_keys( $values ),
611 + 'ok' => $ok,
612 + 'applied' => $applied,
165 613 );
614 +
615 + // Importing an object-cache backend has to actually turn it on.
616 + //
617 + // Writing `backend = redis` only records an intention: the drop-in
618 + // is what makes WordPress use it. Migration never installed one,
619 + // and on a LiteSpeed/W3TC source it also deactivates the source
620 + // plugin, which REMOVES that plugin's drop-in — so a site with a
621 + // working Redis object cache came out of a "successful" import with
622 + // no persistent object cache at all, and nothing said so.
623 + //
624 + // Object_Cache::enable() tests the connection before writing
625 + // anything, so an unreachable server degrades to a reported
626 + // failure rather than a broken drop-in. (#218 / #217)
627 + if ( 'object-cache' === $slug && $ok && ! empty( $meaningful['backend'] ) ) {
628 + $results[ $slug ] = self::enable_object_cache( $results[ $slug ] );
629 + }
166 630 }
167 - if ( ! empty( $results ) ) {
631 + // Mark the source imported on the same terms the handover uses.
632 + // Gating this on completed_successfully() meant a host without Redis
633 + // — where the object cache can never enable — never recorded the
634 + // import at all, so the migration notice came straight back after a
635 + // migration that had worked. (#189)
636 + if ( self::safe_to_hand_over( $results ) ) {
168 637 self::mark_imported( $source_id );
169 638 }
170 639 return $results;
171 640 }
172 641
642 + /**
643 + * Whether an apply result represents a complete, retry-free import.
644 + *
645 + * A non-empty applied list proves only that a group was attempted. The
646 + * source must stay actionable when any attempted group reports failure.
647 + *
648 + * @param array $results Per-module apply results.
649 + */
650 + public static function completed_successfully( array $results ): bool {
651 + if ( empty( $results ) ) {
652 + return false;
653 + }
654 +
655 + $applied = false;
656 + foreach ( $results as $result ) {
657 + if ( ! is_array( $result ) || empty( $result['ok'] ) ) {
658 + return false;
659 + }
660 + if ( ! empty( $result['applied'] ) ) {
661 + $applied = true;
662 + }
663 + }
664 +
665 + return $applied;
666 + }
667 +
668 + /**
669 + * Modules whose failure must NOT block switching the old plugin off.
670 + *
671 + * `completed_successfully()` is all-or-nothing, which is right for
672 + * reporting but wrong as a deactivation gate: it made one optional module
673 + * veto the whole handover. The object cache is the case that bites —
674 + * enabling it needs a running Redis or Memcached, so on a host without
675 + * one it ALWAYS fails, and a site that migrated perfectly was left with
676 + * both cache plugins active while the notice had promised otherwise.
677 + *
678 + * These are additive extras: the site is no worse off without them than
679 + * it was before the migration. Page caching is deliberately absent — if
680 + * THAT did not take, the old plugin has to stay on. (#189)
681 + */
682 + private const OPTIONAL_FOR_HANDOVER = array(
683 + 'object-cache',
684 + 'cdn',
685 + 'preloader',
686 + /*
687 + * `cache` is optional for the HANDOVER decision specifically, and it
688 + * has to be, because the source itself is usually why it failed: the
689 + * source holds advanced-cache.php, so page cache cannot apply, so we
690 + * refuse to deactivate the source, so it keeps holding the drop-in.
691 + * That loop is what left sites with no cache at all (#391).
692 + *
693 + * Deactivating first and enabling after is the resolution -- see
694 + * MigrationModule::restore_own_environment(), which now turns caching
695 + * on once the field is free. A cache failure is still reported to the
696 + * user either way; it just no longer vetoes the switch-off the notice
697 + * already promised.
698 + */
699 + 'cache',
700 + );
701 +
702 + /**
703 + * Is it safe to switch the source plugin off?
704 + *
705 + * Stricter than "did anything import" and looser than "did everything":
706 + * every module that is not an optional extra must have applied cleanly,
707 + * and at least one must have applied at all. A failure in an optional
708 + * module is reported to the user either way — it just does not veto the
709 + * handover the notice already promised.
710 + *
711 + * @param array<string,array<string,mixed>> $results apply() output.
712 + */
713 + public static function safe_to_hand_over( array $results ): bool {
714 + if ( empty( $results ) ) {
715 + return false;
716 + }
717 +
718 + $applied = false;
719 + foreach ( $results as $slug => $result ) {
720 + if ( ! is_array( $result ) ) {
721 + return false;
722 + }
723 + if ( empty( $result['ok'] ) ) {
724 + if ( ! in_array( (string) $slug, self::OPTIONAL_FOR_HANDOVER, true ) ) {
725 + return false;
726 + }
727 + continue;
728 + }
729 + if ( ! empty( $result['applied'] ) ) {
730 + $applied = true;
731 + }
732 + }
733 +
734 + return $applied;
735 + }
736 +
737 + /**
738 + * Install the object-cache drop-in for a just-imported backend, and fold
739 + * the outcome into that module's result row.
740 + *
741 + * A failure here is reported, never silent: the import has already told
742 + * the user their object cache came across.
743 + *
744 + * @param array $result The module's result row so far.
745 + * @return array The row, with ok/message reflecting the drop-in install.
746 + */
747 + private static function enable_object_cache( array $result ): array {
748 + if ( ! class_exists( __NAMESPACE__ . '\\Object_Cache' ) ) {
749 + return $result;
750 + }
751 +
752 + $opts = (array) Settings_Manager::get( 'object-cache' );
753 + // An import IS the user switching away from the source plugin, so it
754 + // takes the same switch path: the source's own object cache is turned
755 + // off through its settings (or the plugin deactivated) before ours goes
756 + // in, instead of being overwritten while it stays on. (#686)
757 + $state = Object_Cache::enable( $opts, array( 'takeover' => true ) );
758 +
759 + $result['ok'] = ! empty( $state['ok'] );
760 + $result['object_cache_ready'] = ! empty( $state['ok'] );
761 + if ( empty( $state['ok'] ) ) {
762 + // Surfaced by the panel instead of a green "imported" message.
763 + $result['message'] = (string) ( $state['message'] ?? 'Could not enable the object cache.' );
764 + }
765 +
766 + return $result;
767 + }
768 +
173 769 // ─────────────────────────── WP Rocket ───────────────────────────
174 770
175 771 public static function detect_wp_rocket(): ?array {
176 772 $opt = get_option( 'wp_rocket_settings', null );
@@ -186,11 +782,24 @@
186 782 */
187 783 public static function plan_wp_rocket( array $r ): array {
188 784 $patch = array();
189 785 // Page caching.
786 + // WP Rocket has no master on/off switch — installing and activating it
787 + // IS enabling page caching, so a detected settings blob means the
788 + // source site was caching. `cache_logged_user` is NOT that switch: it
789 + // controls whether LOGGED-IN users get cached pages. Reading it as the
790 + // master meant the most ordinary WP Rocket configuration of all —
791 + // caching on, but not for logged-in users (`cache_logged_user = 0`) —
792 + // imported as caching OFF, the exact inverse of the user's intent. The
793 + // `! isset` fallback then made a missing key mean ON, so the result was
794 + // right only by accident. (#222 F2)
190 795 $patch['cache'] = array(
191 - 'enabled' => ! empty( $r['cache_logged_user'] ) || ! isset( $r['cache_logged_user'] ),
192 - 'expiry_hours' => isset( $r['purge_cron_interval'] ) ? max( 1, (int) ( $r['purge_cron_interval'] / 3600 ) ) : 24,
796 + 'enabled' => true,
797 + // The Cache module's TTL setting is `cache_expiry` (hours), NOT
798 + // `expiry_hours` — the latter is a dead key nothing reads, so the
799 + // imported lifetime was silently dropped. Clamp to the same 1–720h
800 + // range the Cache schema + LiteSpeed importer use. (FBS-83144)
801 + 'cache_expiry' => isset( $r['purge_cron_interval'] ) ? max( 1, min( 720, (int) ( (int) $r['purge_cron_interval'] / 3600 ) ) ) : 24,
193 802 );
194 803 // Excluded URLs / cookies — both are arrays of strings in WP Rocket.
195 804 if ( ! empty( $r['cache_reject_uri'] ) && is_array( $r['cache_reject_uri'] ) ) {
196 805 $patch['cache']['excluded_urls'] = array_values( array_filter( array_map( 'strval', $r['cache_reject_uri'] ) ) );
@@ -214,13 +823,21 @@
214 823 'lazy_images' => ! empty( $r['lazyload'] ),
215 824 'lazy_iframes' => ! empty( $r['lazyload_iframes'] ),
216 825 'lazy_videos' => ! empty( $r['lazyload_youtube'] ),
217 826 );
827 + if ( ! empty( $r['lazyload_css_bg_img'] ) ) {
828 + $patch['lazy']['lazy_background_images'] = true;
829 + }
218 830
219 - // GZIP — WP Rocket writes its own .htaccess; we infer the toggle from "do_cloudflare" etc.
220 - if ( isset( $r['do_caching_mobile_files'] ) ) {
221 - $patch['cache']['mobile_separate'] = (bool) $r['do_caching_mobile_files'];
222 - }
831 + // Separate Mobile Cache — do NOT import this as ON. WP Rocket's
832 + // "separate cache files for mobile" is frequently left on by habit even
833 + // when the site serves identical HTML to every device, and xSpeed's
834 + // static-file fast path is device-blind — enabling mobile_separate
835 + // DISABLES it, silently dropping the site from HIT (nginx) to HIT (php).
836 + // Instead, keep the fast path (mobile_separate stays false) and flag it
837 + // for review so the dashboard can prompt the user to re-enable it only
838 + // if their site really differs per device. (FBS-83144 / FBS-83145)
839 + self::map_mobile_separate( $patch, ! empty( $r['do_caching_mobile_files'] ) );
223 840
224 841 // Preloader.
225 842 if ( ! empty( $r['manual_preload'] ) || ! empty( $r['sitemap_preload'] ) ) {
226 843 $patch['preloader'] = array(
@@ -231,8 +848,19 @@
231 848 $patch['preloader']['sitemap_urls'] = array_values( array_filter( array_map( 'strval', $r['sitemap_preload_url'] ) ) );
232 849 }
233 850 }
234 851
852 + // Bloat — WP Rocket's "Disable Emoji" and "Disable WordPress embeds".
853 + $bloat = array_filter(
854 + array(
855 + 'disable_emojis' => ! empty( $r['emoji'] ),
856 + 'disable_oembed' => ! empty( $r['embeds'] ),
857 + )
858 + );
859 + if ( $bloat ) {
860 + $patch['bloat'] = $bloat;
861 + }
862 +
235 863 // CDN — WP Rocket stores CDN hosts in cdn_cnames (array).
236 864 if ( ! empty( $r['cdn'] ) && ! empty( $r['cdn_cnames'] ) && is_array( $r['cdn_cnames'] ) ) {
237 865 $first = (string) ( $r['cdn_cnames'][0] ?? '' );
238 866 if ( '' !== $first ) {
@@ -298,47 +926,289 @@
298 926
299 927 public static function plan_w3tc( array $r ): array {
300 928 $patch = array();
301 929 $patch['cache'] = array(
302 - 'enabled' => ! empty( $r['pgcache.enabled'] ),
303 - 'expiry_hours' => isset( $r['pgcache.lifetime'] ) ? max( 1, (int) ( (int) $r['pgcache.lifetime'] / 3600 ) ) : 24,
930 + 'enabled' => ! empty( $r['pgcache.enabled'] ),
931 + // Cache module reads `cache_expiry` (hours), not the dead
932 + // `expiry_hours` key — see plan_wp_rocket. (FBS-83144)
933 + // W3TC stores this in SECONDS and sub-hour values are common (900 /
934 + // 1800 are its own defaults). Integer division floored those to 0
935 + // and max(1, …) then bumped them to a full hour, so a 5-minute
936 + // lifetime silently became 12x longer. xSpeed's cache_expiry is
937 + // hour-granular, so the closest honest answer is to round to
938 + // nearest and keep the 1-hour floor for anything under 30 minutes.
939 + // preview_notes() tells the user when the source value could not be
940 + // represented exactly. (#218)
941 + 'cache_expiry' => isset( $r['pgcache.lifetime'] ) ? self::seconds_to_hours( (int) $r['pgcache.lifetime'] ) : 24,
304 942 );
305 - if ( ! empty( $r['pgcache.reject.uri'] ) && is_array( $r['pgcache.reject.uri'] ) ) {
306 - $patch['cache']['excluded_urls'] = array_values( array_filter( array_map( 'strval', $r['pgcache.reject.uri'] ) ) );
943 + $w3_list = static function ( $key ) use ( $r ): array {
944 + $v = $r[ $key ] ?? null;
945 + return is_array( $v ) ? array_values( array_filter( array_map( 'strval', $v ) ) ) : array();
946 + };
947 +
948 + foreach ( array(
949 + 'pgcache.reject.uri' => 'excluded_urls',
950 + 'pgcache.reject.cookie' => 'excluded_cookies',
951 + 'pgcache.reject.ua' => 'bypass_user_agents',
952 + ) as $src => $dest ) {
953 + $vals = $w3_list( $src );
954 + if ( $vals ) {
955 + $patch['cache'][ $dest ] = $vals;
956 + }
307 957 }
308 958
959 + // `pgcache.accept.qs` is deliberately NOT imported. W3TC ships ~100
960 + // tracking parameters in it by default, so importing it wholesale
961 + // would bury the user's own additions under a stock list — and
962 + // apply() unions lists, which would make that permanent. Our own
963 + // ignored_query_params default already covers the same ground.
964 + // (#218)
965 +
966 + self::map_mobile_separate( $patch, ! empty( $r['mobile.enabled'] ) );
967 +
968 + // W3TC's minify "method" encodes BOTH operations in one value:
969 + // 'minify' | 'combine' | 'both'. Combining is on for the latter two.
970 + $method_combines = static function ( $key ) use ( $r ): bool {
971 + $m = isset( $r[ $key ] ) ? (string) $r[ $key ] : '';
972 + return 'combine' === $m || 'both' === $m;
973 + };
974 +
975 + // The master switch gates every child mapping.
976 + //
977 + // W3TC keeps its child defaults POPULATED while `minify.enabled` is
978 + // off — `minify.css.enable`, `minify.js.enable` and the method fields
979 + // all read as truthy on a site that has minification deliberately
980 + // switched off. Reading the children alone therefore imported Minify
981 + // CSS/JS and Combine CSS/JS as ON for a user who had turned the whole
982 + // feature off, which can change front-end output and introduce the
983 + // exact CSS/JS regressions migration is supposed to avoid.
984 + //
985 + // Every other W3TC block here already gates this way — pgcache on
986 + // `pgcache.enabled`, lazy load on `lazyload.enabled`, browser cache on
987 + // `browsercache.enabled`, object cache on `objectcache.enabled`.
988 + // Minify was the one that did not. (#218)
989 + $minify_on = ! empty( $r['minify.enabled'] );
990 +
309 991 $patch['minify'] = array(
310 - 'minify_html' => ! empty( $r['minify.html.enable'] ),
311 - 'minify_css' => ! empty( $r['minify.css.enable'] ),
312 - 'minify_js' => ! empty( $r['minify.js.enable'] ),
992 + 'minify_html' => $minify_on && ! empty( $r['minify.html.enable'] ),
993 + 'minify_css' => $minify_on && ! empty( $r['minify.css.enable'] ),
994 + 'minify_js' => $minify_on && ! empty( $r['minify.js.enable'] ),
995 + // There is no `minify.css.combine`; CSS combining lives in the
996 + // method. JS splits its combine flag across three placements, and
997 + // any one of them means the user wanted combining.
998 + 'combine_css' => $minify_on && $method_combines( 'minify.css.method' ),
999 + 'combine_js' => $minify_on && (
1000 + $method_combines( 'minify.js.method' )
1001 + || ! empty( $r['minify.js.combine.header'] )
1002 + || ! empty( $r['minify.js.combine.body'] )
1003 + || ! empty( $r['minify.js.combine.footer'] )
1004 + ),
313 1005 );
314 1006
1007 + // ── Lazy load ────────────────────────────────────────────────────
1008 + if ( ! empty( $r['lazyload.enabled'] ) ) {
1009 + $patch['lazy'] = array( 'lazy_images' => true );
1010 + $excluded = $w3_list( 'lazyload.exclude' );
1011 + if ( $excluded ) {
1012 + $patch['lazy']['excluded_images'] = $excluded;
1013 + }
1014 + }
1015 +
1016 + // ── Browser cache + compression ──────────────────────────────────
1017 + if ( ! empty( $r['browsercache.enabled'] ) ) {
1018 + $patch['browser-cache'] = array( 'enabled' => true );
1019 + foreach ( array(
1020 + 'browsercache.cssjs.lifetime' => 'asset_ttl',
1021 + 'browsercache.html.lifetime' => 'html_ttl',
1022 + ) as $src => $dest ) {
1023 + if ( ! empty( $r[ $src ] ) ) {
1024 + $patch['browser-cache'][ $dest ] = (int) $r[ $src ];
1025 + }
1026 + }
1027 + // W3TC has a compression toggle per content type; xSpeed has one
1028 + // switch, so any of them being on means the user wanted GZIP.
1029 + if ( ! empty( $r['browsercache.html.compression'] ) || ! empty( $r['browsercache.cssjs.compression'] ) || ! empty( $r['browsercache.other.compression'] ) ) {
1030 + $patch['gzip'] = array( 'gzip_enabled' => true );
1031 + }
1032 + }
1033 +
1034 + // ── Preloader (W3TC calls it "cache priming") ────────────────────
1035 + if ( ! empty( $r['pgcache.prime.enabled'] ) ) {
1036 + $patch['preloader'] = array( 'enabled' => true );
1037 + if ( ! empty( $r['pgcache.prime.sitemap'] ) ) {
1038 + $patch['preloader']['sitemap_url'] = (string) $r['pgcache.prime.sitemap'];
1039 + }
1040 + if ( ! empty( $r['pgcache.prime.interval'] ) ) {
1041 + $patch['preloader']['schedule'] = self::seconds_to_schedule( (int) $r['pgcache.prime.interval'] );
1042 + }
1043 + if ( ! empty( $r['pgcache.prime.post.update.enabled'] ) ) {
1044 + $patch['preloader']['warm_on_publish'] = true;
1045 + }
1046 + }
1047 +
1048 + // ── Bloat ────────────────────────────────────────────────────────
1049 + if ( ! empty( $r['jquerymigrate.disabled'] ) ) {
1050 + $patch['bloat'] = array( 'strip_jquery_migrate' => true );
1051 + }
1052 +
315 1053 if ( ! empty( $r['objectcache.enabled'] ) && ! empty( $r['objectcache.engine'] ) ) {
316 - $patch['object-cache'] = array(
317 - 'backend' => 'memcached' === $r['objectcache.engine'] ? 'memcached' : 'redis',
318 - );
319 - if ( ! empty( $r['objectcache.servers'] ) && is_array( $r['objectcache.servers'] ) ) {
320 - $first = (string) ( $r['objectcache.servers'][0] ?? '' );
321 - if ( false !== strpos( $first, ':' ) ) {
322 - [ $host, $port ] = explode( ':', $first, 2 );
323 - if ( 'redis' === ( $patch['object-cache']['backend'] ?? '' ) ) {
324 - $patch['object-cache']['redis_host'] = $host;
325 - $patch['object-cache']['redis_port'] = (int) $port;
326 - } else {
327 - $patch['object-cache']['memcached_host'] = $host;
328 - $patch['object-cache']['memcached_port'] = (int) $port;
1054 + $is_memcached = 'memcached' === $r['objectcache.engine'];
1055 + $engine = $is_memcached ? 'memcached' : 'redis';
1056 +
1057 + $patch['object-cache'] = array( 'backend' => $engine );
1058 +
1059 + // W3TC namespaces these by engine — `objectcache.redis.servers` /
1060 + // `objectcache.memcached.servers`. There is no bare
1061 + // `objectcache.servers`, so the host/port branch here could never
1062 + // run for a W3TC source: a site with Redis on a non-default host or
1063 + // port silently fell back to 127.0.0.1:6379. (#218)
1064 + $servers = $r[ 'objectcache.' . $engine . '.servers' ] ?? null;
1065 + if ( ! empty( $servers ) && is_array( $servers ) ) {
1066 + $first = (string) ( $servers[0] ?? '' );
1067 +
1068 + // A scheme says HOW to connect, not WHERE. Left in place it
1069 + // becomes the hostname: Redis_Client::connect() builds
1070 + // "tcp://{$host}:{$port}", so `tls://redis.example` produces
1071 + // `tcp://tls://redis.example:6380` and resolution fails on the
1072 + // literal host "tls". The same string is also written into the
1073 + // drop-in's WP_REDIS_HOST, so the imported object cache could
1074 + // never connect. A managed Redis on TLS is the common case.
1075 + // Bracketed IPv6 is left alone — stream_socket_client() wants
1076 + // the brackets. (#224)
1077 + // W3TC writes a unix socket as `unix:/path`. Split on the
1078 + // colon like a host:port, it imported host "unix" and port 0.
1079 + // A socket carries no port, so only the host is set. (#566)
1080 + $socket = 'redis' === $engine ? Redis_Client::socket_path( $first ) : '';
1081 + if ( '' !== $socket ) {
1082 + $patch['object-cache']['redis_host'] = $socket;
1083 + $first = '';
1084 + }
1085 + $first = (string) preg_replace( '#^[a-z][a-z0-9+.-]*://#i', '', $first );
1086 + $separator = strrpos( $first, ':' );
1087 + if ( false !== $separator ) {
1088 + $host = substr( $first, 0, $separator );
1089 + $port = substr( $first, $separator + 1 );
1090 + $patch['object-cache'][ $engine . '_host' ] = $host;
1091 + $patch['object-cache'][ $engine . '_port' ] = (int) $port;
1092 + }
1093 + }
1094 +
1095 + // Everything else that has a destination in ObjectCacheModule and
1096 + // was previously dropped. A non-zero Redis DB index matters most:
1097 + // the connection would succeed while pointing at the wrong dataset.
1098 + $copy = $is_memcached
1099 + ? array( 'objectcache.memcached.persistent' => 'persistent' )
1100 + : array(
1101 + 'objectcache.redis.dbid' => 'redis_database',
1102 + 'objectcache.redis.password' => 'redis_password',
1103 + 'objectcache.redis.persistent' => 'persistent',
1104 + 'objectcache.redis.timeout' => 'connection_timeout',
1105 + );
1106 + $numeric_dests = array( 'redis_database', 'connection_timeout' );
1107 + foreach ( $copy as $src => $dest ) {
1108 + if ( ! isset( $r[ $src ] ) || '' === $r[ $src ] ) {
1109 + continue;
1110 + }
1111 + // W3TC stores an unset timeout / db index as 0, which carries no
1112 + // intent — emitting it would put a meaningless value in the plan
1113 + // preview the user is asked to confirm. Only the numeric fields
1114 + // get that treatment: casting a password to int would read
1115 + // "s3cret" as 0 and silently drop it.
1116 + $is_numeric_dest = in_array( $dest, $numeric_dests, true );
1117 + if ( $is_numeric_dest && 0 === (int) $r[ $src ] ) {
1118 + continue;
1119 + }
1120 + // W3 Total Cache 2.8+ encrypts secrets in its config file
1121 + // (Util_Crypto, `enc:v1:` prefix). Copying the ciphertext
1122 + // through would hand Redis a password that can never
1123 + // authenticate — and it is exactly the password-protected
1124 + // sources this mapping exists to serve. Decrypt with W3TC's
1125 + // own helper; if that is unavailable (no crypto key, plugin
1126 + // files already gone), SKIP the field rather than import an
1127 + // unusable value: a missing password is something the user
1128 + // can fix in one edit, a silently wrong one is not. (#224 F1)
1129 + if ( 'redis_password' === $dest ) {
1130 + $secret = self::decrypt_w3tc_secret( (string) $r[ $src ] );
1131 + if ( null === $secret ) {
1132 + continue;
329 1133 }
1134 + $patch['object-cache'][ $dest ] = $secret;
1135 + continue;
330 1136 }
1137 + $patch['object-cache'][ $dest ] = $is_numeric_dest
1138 + ? (int) $r[ $src ]
1139 + : $r[ $src ];
331 1140 }
1141 + // W3TC's Cache_Redis only ever calls auth( $password ) — it has no
1142 + // ACL username support — so a W3TC source never carries one and we
1143 + // must not invent a redis_user here.
332 1144 }
333 1145
334 - if ( ! empty( $r['browsercache.enabled'] ) ) {
335 - $patch['browser-cache'] = array(
336 - 'enabled' => true,
337 - );
1146 + return $patch;
1147 + }
1148 +
1149 + /**
1150 + * Resolve a W3 Total Cache secret to plaintext.
1151 + *
1152 + * W3TC 2.8+ stores secrets encrypted with its own `Util_Crypto`, marked
1153 + * by an `enc:v1:` prefix. A plaintext value (older W3TC, or a config
1154 + * written before encryption landed) is returned unchanged.
1155 + *
1156 + * Returns null when the value is encrypted but cannot be decrypted —
1157 + * W3TC's classes are not loadable, or its crypto key is gone. Callers
1158 + * MUST treat null as "skip this field", never as an empty password:
1159 + * importing the ciphertext guarantees an auth failure, and importing an
1160 + * empty string would silently drop a password the source really had.
1161 + *
1162 + * @param string $value Raw value from the W3TC config.
1163 + * @return string|null Plaintext, or null when it cannot be resolved.
1164 + */
1165 + private static function decrypt_w3tc_secret( string $value ): ?string {
1166 + if ( 0 !== strpos( $value, 'enc:' ) ) {
1167 + return $value;
338 1168 }
339 1169
340 - return $patch;
1170 + if ( ! class_exists( '\\W3TC\\Util_Crypto' ) ) {
1171 + return null;
1172 + }
1173 +
1174 + // W3TC's method is envelope_decrypt(), NOT decrypt(). Guarding on the
1175 + // wrong name meant method_exists() was false on every install, the
1176 + // helper returned null before it ever ran, and the password was
1177 + // silently dropped from every import — the exact users the decrypt
1178 + // support was written for. Verified against W3TC 2.10.5:
1179 + //
1180 + // ::decrypt() MISSING
1181 + // ::envelope_decrypt() EXISTS
1182 + // ::is_envelope() EXISTS
1183 + //
1184 + // Kept as a list so an older/newer W3TC that renames it again
1185 + // degrades to "skip the field" rather than to a fatal. (#218 F1)
1186 + $method = null;
1187 + foreach ( array( 'envelope_decrypt', 'decrypt' ) as $candidate ) {
1188 + if ( method_exists( '\\W3TC\\Util_Crypto', $candidate ) ) {
1189 + $method = $candidate;
1190 + break;
1191 + }
1192 + }
1193 + if ( null === $method ) {
1194 + return null;
1195 + }
1196 +
1197 + try {
1198 + $plain = \W3TC\Util_Crypto::$method( $value );
1199 + } catch ( \Throwable $e ) {
1200 + return null;
1201 + }
1202 +
1203 + // A failed decrypt can come back as false/null/'' or as the
1204 + // untouched ciphertext depending on the failure mode. None of those
1205 + // are a usable password.
1206 + if ( ! is_string( $plain ) || '' === $plain || 0 === strpos( $plain, 'enc:' ) ) {
1207 + return null;
1208 + }
1209 +
1210 + return $plain;
341 1211 }
342 1212
343 1213 // ─────────────────────────── WP Super Cache ──────────────────────
344 1214
@@ -355,9 +1225,9 @@
355 1225 return $cfg;
356 1226 }
357 1227 if ( self::plugin_active( 'wp-super-cache/wp-cache.php' ) ) {
358 1228 // Active but unconfigured — expose the on/off intent only.
359 - return array( 'wp_cache_enabled' => defined( 'WPCACHEHOME' ) );
1229 + return array( 'cache_enabled' => defined( 'WPCACHEHOME' ) );
360 1230 }
361 1231 return null;
362 1232 }
363 1233
@@ -380,17 +1250,34 @@
380 1250 $raw = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- one-shot read of another plugin's config file.
381 1251 if ( false === $raw || '' === $raw ) {
382 1252 return null;
383 1253 }
384 - $out = array();
385 - $keys = array( 'wp_cache_enabled', 'wp_cache_mod_rewrite', 'wp_cache_mobile_enabled', 'wp_cache_not_logged_in', 'wp_cache_make_known_anon' );
1254 + $out = array();
1255 +
1256 + // `$cache_enabled` is the master switch and `$super_cache_enabled`
1257 + // selects mod_rewrite mode. WP Super Cache does NOT define
1258 + // `$wp_cache_enabled` — reading that name meant the on/off intent was
1259 + // never populated, plan_wpsc() computed `enabled => false`, and
1260 + // meaningful_values() then dropped the false boolean entirely. A site
1261 + // actively serving cached HTML migrated to caching OFF, silently. (#219)
1262 + $keys = array( 'cache_enabled', 'super_cache_enabled', 'wp_cache_mod_rewrite', 'wp_cache_mobile_enabled', 'wp_cache_make_known_anon' );
386 1263 foreach ( $keys as $key ) {
387 1264 // Match `$key = 1;` / `$key = '1';` / `$key = true;` etc.
388 1265 if ( preg_match( '/\$' . preg_quote( $key, '/' ) . '\s*=\s*([^;]+);/', $raw, $m ) ) {
389 - $val = trim( $m[1], " \t'\"" );
1266 + $val = trim( $m[1], " \t'\"" );
390 1267 $out[ $key ] = in_array( strtolower( $val ), array( '1', 'true' ), true );
391 1268 }
392 1269 }
1270 +
1271 + // Tri-state, so it cannot go through the boolean cast above:
1272 + // 0 = cache everyone, 1 = skip visitors carrying any cookie,
1273 + // 2 = skip logged-in visitors (WPSC's own recommended setting).
1274 + // Casting collapsed 2 to false — the exact inverse of the user's
1275 + // intent — which is harmless only while nothing maps the key. (#219)
1276 + if ( preg_match( '/\$wp_cache_not_logged_in\s*=\s*([^;]+);/', $raw, $m ) ) {
1277 + $out['wp_cache_not_logged_in'] = (int) trim( $m[1], " \t'\"" );
1278 + }
1279 +
393 1280 return ! empty( $out ) ? $out : null;
394 1281 }
395 1282
396 1283 /** Thin wrapper so detection works before admin plugin.php is loaded. */
@@ -404,14 +1291,20 @@
404 1291 return isset( $network[ $plugin ] );
405 1292 }
406 1293
407 1294 public static function plan_wpsc( array $r ): array {
408 - return array(
1295 + $plan = array(
409 1296 'cache' => array(
410 - 'enabled' => ! empty( $r['wp_cache_enabled'] ),
411 - 'mobile_separate' => ! empty( $r['wp_cache_mobile_enabled'] ),
1297 + // Either flag means WP Super Cache was serving: `cache_enabled`
1298 + // is the master switch, `super_cache_enabled` only picks
1299 + // mod_rewrite over PHP delivery.
1300 + 'enabled' => ! empty( $r['cache_enabled'] ) || ! empty( $r['super_cache_enabled'] ),
412 1301 ),
413 1302 );
1303 + // See plan_wp_rocket: never import Separate Mobile Cache as ON — it
1304 + // disables the device-blind static fast path. Flag for review instead.
1305 + self::map_mobile_separate( $plan, ! empty( $r['wp_cache_mobile_enabled'] ) );
1306 + return $plan;
414 1307 }
415 1308
416 1309 // ─────────────────────────── LiteSpeed Cache ─────────────────────
417 1310
@@ -448,11 +1341,9 @@
448 1341 $name = substr( (string) $row['option_name'], strlen( 'litespeed.conf.' ) );
449 1342 if ( '' === $name || '_version' === $name ) {
450 1343 continue;
451 1344 }
452 - // option_value is stored serialized by WP; maybe_unserialize
453 - // gives back arrays (list settings) or scalars as-is.
454 - $conf[ $name ] = maybe_unserialize( $row['option_value'] );
1345 + $conf[ $name ] = self::decode_litespeed_value( (string) $row['option_value'] );
455 1346 }
456 1347 if ( ! empty( $conf ) ) {
457 1348 return $conf;
458 1349 }
@@ -466,8 +1357,34 @@
466 1357 return is_array( $opt ) && ! empty( $opt ) ? $opt : null;
467 1358 }
468 1359
469 1360 /**
1361 + * Decode one `litespeed.conf.*` option value.
1362 + *
1363 + * LiteSpeed v4+ stores its list settings as JSON strings, not
1364 + * PHP-serialized arrays, so maybe_unserialize() hands the JSON straight
1365 + * back as a string. plan_litespeed()'s $list() helper then splits it on
1366 + * newlines — which JSON has none of — producing a ONE-element array
1367 + * holding the entire blob. Every exclusion rule imported that way is
1368 + * dead: the list no longer matches anything, so cart, checkout and
1369 + * account pages become publicly cacheable while the import reports
1370 + * success. (#217)
1371 + *
1372 + * Try JSON first for anything shaped like it, and fall back to
1373 + * maybe_unserialize() so v3 / legacy installs keep working.
1374 + */
1375 + private static function decode_litespeed_value( string $raw ) {
1376 + $trimmed = trim( $raw );
1377 + if ( '' !== $trimmed && ( '[' === $trimmed[0] || '{' === $trimmed[0] ) ) {
1378 + $decoded = json_decode( $trimmed, true );
1379 + if ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) ) {
1380 + return $decoded;
1381 + }
1382 + }
1383 + return maybe_unserialize( $raw );
1384 + }
1385 +
1386 + /**
470 1387 * Translate LiteSpeed's `litespeed.conf` into xSpeed module patches.
471 1388 * LiteSpeed uses dotted keys; values are mostly bool-ish (1/0/'1').
472 1389 * We map only the settings that have a clean xSpeed equivalent and
473 1390 * leave the rest untouched so nothing is silently mis-imported.
@@ -489,9 +1406,25 @@
489 1406 return array();
490 1407 }
491 1408 return array_values( array_filter( array_map( 'trim', array_map( 'strval', $v ) ) ) );
492 1409 };
493 - $set_list = static function ( array &$dest, string $dest_key, array $vals ): void {
1410 + // LiteSpeed writes a regex rule bare (`^/secret-.*`); xSpeed marks one
1411 + // with a leading `~` (see CacheModule's own `~wp-.*\.php` default) and
1412 + // treats anything else as a literal/glob. Imported unchanged, a
1413 + // LiteSpeed regex became a literal that matches nothing — the same
1414 + // silent loss of protection as the JSON bug above, just narrower. Only
1415 + // rules carrying an unmistakable regex metacharacter are converted, so
1416 + // a plain path like `/cart` stays the literal it already is. (#217)
1417 + $to_xspeed_pattern = static function ( string $rule ): string {
1418 + if ( '' === $rule || '~' === $rule[0] ) {
1419 + return $rule;
1420 + }
1421 + return preg_match( '/[\^$|]|\.\*|\.\+|\[.+\]|\\\\[dwsb]/', $rule ) ? '~' . $rule : $rule;
1422 + };
1423 + $set_list = static function ( array &$dest, string $dest_key, array $vals ) use ( $to_xspeed_pattern ): void {
1424 + if ( in_array( $dest_key, array( 'excluded_urls', 'excluded_patterns' ), true ) ) {
1425 + $vals = array_map( $to_xspeed_pattern, $vals );
1426 + }
494 1427 if ( $vals ) {
495 1428 $dest[ $dest_key ] = $vals;
496 1429 }
497 1430 };
@@ -499,11 +1432,13 @@
499 1432 $patch = array();
500 1433
501 1434 // ── Page cache ────────────────────────────────────────────────
502 1435 $patch['cache'] = array(
503 - 'enabled' => $on( 'cache' ) || $on( 'cache-priv' ),
504 - 'mobile_separate' => $on( 'cache-mobile' ),
1436 + 'enabled' => $on( 'cache' ) || $on( 'cache-priv' ),
505 1437 );
1438 + // See plan_wp_rocket: never import Separate Mobile Cache as ON — it
1439 + // disables the device-blind static fast path. Flag for review instead.
1440 + self::map_mobile_separate( $patch, $on( 'cache-mobile' ) );
506 1441 // TTL: LiteSpeed stores cache-ttl_pub in seconds → xSpeed wants hours.
507 1442 if ( isset( $r['cache-ttl_pub'] ) && (int) $r['cache-ttl_pub'] > 0 ) {
508 1443 $patch['cache']['cache_expiry'] = max( 1, min( 720, (int) ( (int) $r['cache-ttl_pub'] / 3600 ) ) );
509 1444 }
@@ -509,9 +1444,12 @@
509 1444 }
510 1445 // Excluded URIs / cookies / user-agents / dropped query strings.
511 1446 $set_list( $patch['cache'], 'excluded_urls', $list( 'cache-exc' ) );
512 1447 $set_list( $patch['cache'], 'excluded_cookies', $list( 'cache-exc_cookies' ) );
513 - $set_list( $patch['cache'], 'bypass_user_agents', $list( 'cache-exc_useragent' ) );
1448 + // `cache-exc_useragents`, plural — LiteSpeed's O_CACHE_EXC_USERAGENTS.
1449 + // The singular spelling matched nothing, so the list always imported
1450 + // empty, and an empty field looks "not configured" rather than lost. (#217)
1451 + $set_list( $patch['cache'], 'bypass_user_agents', $list( 'cache-exc_useragents' ) );
514 1452 // LiteSpeed "drop query string" list ≈ xSpeed ignored_query_params.
515 1453 $set_list( $patch['cache'], 'ignored_query_params', $list( 'cache-drop_qs' ) );
516 1454
517 1455 // ── Minify / optimization ─────────────────────────────────────
@@ -520,12 +1458,16 @@
520 1458 'minify_css' => $on( 'optm-css_min' ),
521 1459 'minify_js' => $on( 'optm-js_min' ),
522 1460 'combine_css' => $on( 'optm-css_comb' ),
523 1461 'combine_js' => $on( 'optm-js_comb' ),
524 - 'defer_js' => $on( 'optm-js_defer' ),
525 - // LiteSpeed "Delay JS" (optm-js_defer === 2 in some versions, or
526 - // the dedicated optm-js_delay flag) → xSpeed delay_js.
527 - 'delay_js' => $on( 'optm-js_delay' ) || ( isset( $r['optm-js_defer'] ) && (int) $r['optm-js_defer'] === 2 ),
1462 + // optm-js_defer is a THREE-WAY switch, not a boolean:
1463 + // 0 = OFF, 1 = Deferred, 2 = Delayed (LiteSpeed's own UI labels,
1464 + // tpl/page_optm/settings_js.tpl.php). The modes replace each other,
1465 + // so 2 must set delay_js INSTEAD of defer_js — the old mapping set
1466 + // both, turning one LiteSpeed choice into two xSpeed transforms
1467 + // that fight each other. (#217)
1468 + 'defer_js' => isset( $r['optm-js_defer'] ) && 1 === (int) $r['optm-js_defer'],
1469 + 'delay_js' => isset( $r['optm-js_defer'] ) && 2 === (int) $r['optm-js_defer'],
528 1470 // Async/“load CSS asynchronously” — LiteSpeed CCSS async.
529 1471 'async_css' => $on( 'optm-css_async' ),
530 1472 // Remove query strings from static resources.
531 1473 'remove_query_strings' => $on( 'optm-qs_rm' ),
@@ -531,10 +1473,16 @@
531 1473 'remove_query_strings' => $on( 'optm-qs_rm' ),
532 1474 );
533 1475 // Defer/delay exclusion list — merge LiteSpeed's JS defer + delay
534 1476 // exclude lists into xSpeed's single defer_js_excluded.
535 - $defer_exc = array_values( array_unique( array_merge( $list( 'optm-js_defer_exc' ), $list( 'optm-js_delay_exc' ) ) ) );
1477 + // `optm-js_delay_exc` does not exist in LiteSpeed. Its delay list is
1478 + // optm-js_delay_inc (O_OPTM_JS_DELAY_INC) — an INCLUDE list naming the
1479 + // scripts to delay, which is xSpeed's delay_js_targets, not an
1480 + // exclusion. Merging it into defer_js_excluded would have inverted the
1481 + // user's intent, so it maps to its own destination below. (#217)
1482 + $defer_exc = $list( 'optm-js_defer_exc' );
536 1483 $set_list( $patch['minify'], 'defer_js_excluded', $defer_exc );
1484 + $set_list( $patch['minify'], 'delay_js_targets', $list( 'optm-js_delay_inc' ) );
537 1485
538 1486 // ── Lazy load (media) ─────────────────────────────────────────
539 1487 $patch['lazy'] = array(
540 1488 'lazy_images' => $on( 'media-lazy' ),
@@ -554,15 +1502,14 @@
554 1502 $patch['fonts'] = array( 'font_display_swap' => true );
555 1503 }
556 1504
557 1505 // ── Disable bloat ─────────────────────────────────────────────
558 - // Only map the one LiteSpeed "remove" toggle with a clean xSpeed
559 - // equivalent: removing the emoji + oEmbed scripts ≈ disable_oembed.
560 - // (LiteSpeed's optm-emoji_rm strips the wp-emoji + wp-embed pair.)
561 - // jQuery-migrate / dashicons / XML-RPC / RSS / REST aren't
562 - // LiteSpeed-managed, so we don't guess at them.
1506 + // optm-emoji_rm is LiteSpeed's only bloat toggle. It used to map to
1507 + // disable_oembed, which turned oEmbed off unasked and left emojis on
1508 + // (#217, #532). jQuery-migrate / dashicons / XML-RPC / RSS / REST
1509 + // aren't LiteSpeed-managed, so we don't guess at them.
563 1510 if ( $on( 'optm-emoji_rm' ) ) {
564 - $patch['bloat'] = array( 'disable_oembed' => true );
1511 + $patch['bloat'] = array( 'disable_emojis' => true );
565 1512 }
566 1513
567 1514 // ── Browser cache (LiteSpeed: cache-browser) ──────────────────
568 1515 if ( $on( 'cache-browser' ) ) {
@@ -617,9 +1564,10 @@
617 1564 $patch['cdn'] = array(
618 1565 'enabled' => true,
619 1566 'cdn_url' => $cdn_url,
620 1567 );
621 - $set_list( $patch['cdn'], 'excluded_patterns', $list( 'cdn-exclude' ) );
1568 + // LiteSpeed's O_CDN_EXC is `cdn-exc`, not `cdn-exclude`. (#217)
1569 + $set_list( $patch['cdn'], 'excluded_patterns', $list( 'cdn-exc' ) );
622 1570 }
623 1571 }
624 1572
625 1573 return $patch;