PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
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 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/class-migration.php +871 -51 1.1.41.3.3 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 ) ) {
@@ -169,13 +272,140 @@
169 272 }
170 273 } elseif ( '' !== $value && null !== $value && 0 !== $value && '0' !== $value ) {
171 274 $out[ $key ] = $value; // Non-empty scalar (e.g. cache_expiry).
172 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)
173 286 }
174 287 return $out;
175 288 }
176 289
177 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 + if ( ! preg_match( '#^([a-z][a-z0-9+.-]*)://#i', $first, $m ) ) {
394 + continue;
395 + }
396 + $notes[] = sprintf(
397 + /* translators: 1: endpoint scheme, e.g. tls. 2: the endpoint as configured in the source plugin. */
398 + __( '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' ),
399 + strtoupper( $m[1] ),
400 + $first
401 + );
402 + }
403 + }
404 + return $notes;
405 + }
406 +
407 + /**
178 408 * Map a source plugin's "separate mobile cache" flag onto the cache patch
179 409 * WITHOUT enabling xSpeed's mobile_separate. The xSpeed static-file fast
180 410 * path is device-blind, so mobile_separate=ON disables it — and a source
181 411 * site that had the flag on very often serves identical HTML to every
@@ -202,8 +432,27 @@
202 432 /**
203 433 * Return the patch that `apply()` would write, without writing.
204 434 */
205 435 public static function preview( string $source_id ): ?array {
436 + $full = self::preview_with_notes( $source_id );
437 +
438 + return null === $full ? null : $full['patch'];
439 + }
440 +
441 + /**
442 + * The preview plan together with the notes that explain it.
443 + *
444 + * preview_notes() exists to tell the user when a source value could not
445 + * be carried over exactly — e.g. a 15-minute W3TC lifetime that xSpeed
446 + * can only express in whole hours. It was written but never called, so
447 + * the panel showed the rounded number as plain fact and the user had no
448 + * way to know their setting had changed. Returning both from one detect
449 + * pass keeps them in lockstep. (#224 F2)
450 + *
451 + * @param string $source_id Source plugin id.
452 + * @return array{patch:array,notes:string[]}|null Null when undetected.
453 + */
454 + public static function preview_with_notes( string $source_id ): ?array {
206 455 $src = self::sources()[ $source_id ] ?? null;
207 456 if ( null === $src ) {
208 457 return null;
209 458 }
@@ -210,9 +459,13 @@
210 459 $raw = call_user_func( $src['detect'] );
211 460 if ( ! is_array( $raw ) ) {
212 461 return null;
213 462 }
214 - return call_user_func( $src['plan'], $raw );
463 +
464 + return array(
465 + 'patch' => call_user_func( $src['plan'], $raw ),
466 + 'notes' => self::preview_notes( $source_id, $raw ),
467 + );
215 468 }
216 469
217 470 /**
218 471 * Read source settings + write the translated patch. Returns the
@@ -237,23 +490,275 @@
237 490 $meaningful = self::meaningful_values( $values );
238 491 if ( empty( $meaningful ) ) {
239 492 continue;
240 493 }
241 - $option = 'xspeed_module_' . $slug;
242 - $cur = (array) get_option( $option, array() );
243 - $next = array_merge( $cur, $meaningful );
244 - $ok = update_option( $option, $next );
494 +
495 + // The page-cache switch is NOT a module setting. It lives in the
496 + // `xspeed_options` blob, and turning it on means installing the
497 + // drop-in, setting WP_CACHE and installing the rewrite — work only
498 + // Cache::toggle() does. Writing it to xspeed_module_cache['enabled']
499 + // put it in a key that is not in CacheModule::settings_schema() and
500 + // that nothing reads, so every importer's `cache.enabled` was a
501 + // dead destination: the import reported success and the site came
502 + // out of it with caching still off. (#219)
503 + $applied_enable = null;
504 + if ( 'cache' === $slug && array_key_exists( 'enabled', $meaningful ) ) {
505 + $applied_enable = (bool) $meaningful['enabled'];
506 + unset( $meaningful['enabled'] );
507 + }
508 +
509 + // Union list values with what is already in EFFECT rather than
510 + // overwriting them.
511 + //
512 + // A source's exclusion list is what IT needed; ours covers cases it
513 + // handled with separate settings we don't read (W3TC has
514 + // pgcache.cache.feed, pgcache.reject.request_head, …). Assigning
515 + // the mapped array straight over ours dropped every xSpeed-specific
516 + // safety rule — /wp-json/, /xmlrpc.php, ~wp-.*\.php, /feed/ and
517 + // ~sitemap(_index)?\.xml among them — and feeds and sitemaps
518 + // started being served from the page cache.
519 + //
520 + // Merge against Settings_Manager::get(), not the raw option: module
521 + // defaults live in the SCHEMA, so on a fresh install the stored
522 + // option is empty and a union with it would still lose all of them.
523 + // The class comment above already promised migration is additive;
524 + // before this that only held for scalars. (#218)
525 + $effective = (array) Settings_Manager::get( $slug );
526 + foreach ( $meaningful as $key => $value ) {
527 + if ( ! is_array( $value ) ) {
528 + continue;
529 + }
530 + $existing = $effective[ $key ] ?? array();
531 + if ( ! is_array( $existing ) || empty( $existing ) ) {
532 + continue;
533 + }
534 + $meaningful[ $key ] = array_values( array_unique( array_merge( $existing, $value ) ) );
535 + }
536 +
537 + // Write through Settings_Manager::update(), not a raw
538 + // update_option().
539 + //
540 + // The raw write skipped everything that makes a settings write
541 + // safe: schema coercion, per-field range/type validation, secret
542 + // encryption at rest, `_version` maintenance, and log_changes() —
543 + // so imported values landed unvalidated and no per-module change
544 + // ever reached the activity log, only the single "Imported
545 + // settings from…" line. It also means a bad value from a source
546 + // plugin could be stored where the UI could never have set it.
547 + //
548 + // update() returns the module's public view, which is also the
549 + // honest success signal: it reflects what is actually stored,
550 + // where update_option()'s return value conflates "no change
551 + // needed" with "write failed". (#218)
552 + $stored = Settings_Manager::update( $slug, $meaningful );
553 +
554 + // Did the intent land? Not "is the stored value identical" — for a
555 + // list we deliberately UNION with the existing rules, so the stored
556 + // array is legitimately bigger than what we passed in. Assert each
557 + // imported value is PRESENT instead, which is what "applied" means
558 + // and what a lossy or rejected write would fail.
559 + //
560 + // Secrets come back masked in the public view, so they are taken on
561 + // trust rather than compared against plaintext.
562 + $ok = ! empty( $stored );
563 + foreach ( $meaningful as $key => $value ) {
564 + if ( ! array_key_exists( $key, $stored ) ) {
565 + // A key the module deliberately owns elsewhere (cache.enabled
566 + // lives in xspeed_options and is applied via Cache::toggle)
567 + // is not a failed write.
568 + continue;
569 + }
570 + if ( is_string( $stored[ $key ] ) && Settings_Manager::is_masked_secret( $stored[ $key ] ) ) {
571 + continue;
572 + }
573 + if ( is_array( $value ) ) {
574 + if ( array_diff( $value, (array) $stored[ $key ] ) ) {
575 + $ok = false;
576 + break;
577 + }
578 + continue;
579 + }
580 + 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.
581 + $ok = false;
582 + break;
583 + }
584 + }
585 +
586 + $applied = array_keys( $meaningful );
587 +
588 + if ( null !== $applied_enable ) {
589 + $state = Cache::toggle( $applied_enable );
590 + $applied[] = 'enabled';
591 + // Enabling is only real if the transaction went through. The
592 + // drop-in alone is not the test: a refusal reports the
593 + // artifacts already on disk, so a site whose xSpeed drop-in
594 + // was still installed read a refused write as success.
595 + if ( ! empty( $state['blocked'] ) ) {
596 + $ok = false;
597 + } elseif ( $applied_enable && empty( $state['dropin_installed'] ) ) {
598 + $ok = false;
599 + }
600 + }
601 +
602 + if ( empty( $applied ) ) {
603 + continue;
604 + }
605 +
245 606 $results[ $slug ] = array(
246 - 'ok' => (bool) $ok,
247 - 'applied' => array_keys( $meaningful ),
607 + 'ok' => $ok,
608 + 'applied' => $applied,
248 609 );
610 +
611 + // Importing an object-cache backend has to actually turn it on.
612 + //
613 + // Writing `backend = redis` only records an intention: the drop-in
614 + // is what makes WordPress use it. Migration never installed one,
615 + // and on a LiteSpeed/W3TC source it also deactivates the source
616 + // plugin, which REMOVES that plugin's drop-in — so a site with a
617 + // working Redis object cache came out of a "successful" import with
618 + // no persistent object cache at all, and nothing said so.
619 + //
620 + // Object_Cache::enable() tests the connection before writing
621 + // anything, so an unreachable server degrades to a reported
622 + // failure rather than a broken drop-in. (#218 / #217)
623 + if ( 'object-cache' === $slug && $ok && ! empty( $meaningful['backend'] ) ) {
624 + $results[ $slug ] = self::enable_object_cache( $results[ $slug ] );
625 + }
249 626 }
250 - if ( ! empty( $results ) ) {
627 + // Mark the source imported on the same terms the handover uses.
628 + // Gating this on completed_successfully() meant a host without Redis
629 + // — where the object cache can never enable — never recorded the
630 + // import at all, so the migration notice came straight back after a
631 + // migration that had worked. (#189)
632 + if ( self::safe_to_hand_over( $results ) ) {
251 633 self::mark_imported( $source_id );
252 634 }
253 635 return $results;
254 636 }
255 637
638 + /**
639 + * Whether an apply result represents a complete, retry-free import.
640 + *
641 + * A non-empty applied list proves only that a group was attempted. The
642 + * source must stay actionable when any attempted group reports failure.
643 + *
644 + * @param array $results Per-module apply results.
645 + */
646 + public static function completed_successfully( array $results ): bool {
647 + if ( empty( $results ) ) {
648 + return false;
649 + }
650 +
651 + $applied = false;
652 + foreach ( $results as $result ) {
653 + if ( ! is_array( $result ) || empty( $result['ok'] ) ) {
654 + return false;
655 + }
656 + if ( ! empty( $result['applied'] ) ) {
657 + $applied = true;
658 + }
659 + }
660 +
661 + return $applied;
662 + }
663 +
664 + /**
665 + * Modules whose failure must NOT block switching the old plugin off.
666 + *
667 + * `completed_successfully()` is all-or-nothing, which is right for
668 + * reporting but wrong as a deactivation gate: it made one optional module
669 + * veto the whole handover. The object cache is the case that bites —
670 + * enabling it needs a running Redis or Memcached, so on a host without
671 + * one it ALWAYS fails, and a site that migrated perfectly was left with
672 + * both cache plugins active while the notice had promised otherwise.
673 + *
674 + * These are additive extras: the site is no worse off without them than
675 + * it was before the migration. Page caching is deliberately absent — if
676 + * THAT did not take, the old plugin has to stay on. (#189)
677 + */
678 + private const OPTIONAL_FOR_HANDOVER = array(
679 + 'object-cache',
680 + 'cdn',
681 + 'preloader',
682 + /*
683 + * `cache` is optional for the HANDOVER decision specifically, and it
684 + * has to be, because the source itself is usually why it failed: the
685 + * source holds advanced-cache.php, so page cache cannot apply, so we
686 + * refuse to deactivate the source, so it keeps holding the drop-in.
687 + * That loop is what left sites with no cache at all (#391).
688 + *
689 + * Deactivating first and enabling after is the resolution -- see
690 + * MigrationModule::restore_own_environment(), which now turns caching
691 + * on once the field is free. A cache failure is still reported to the
692 + * user either way; it just no longer vetoes the switch-off the notice
693 + * already promised.
694 + */
695 + 'cache',
696 + );
697 +
698 + /**
699 + * Is it safe to switch the source plugin off?
700 + *
701 + * Stricter than "did anything import" and looser than "did everything":
702 + * every module that is not an optional extra must have applied cleanly,
703 + * and at least one must have applied at all. A failure in an optional
704 + * module is reported to the user either way — it just does not veto the
705 + * handover the notice already promised.
706 + *
707 + * @param array<string,array<string,mixed>> $results apply() output.
708 + */
709 + public static function safe_to_hand_over( array $results ): bool {
710 + if ( empty( $results ) ) {
711 + return false;
712 + }
713 +
714 + $applied = false;
715 + foreach ( $results as $slug => $result ) {
716 + if ( ! is_array( $result ) ) {
717 + return false;
718 + }
719 + if ( empty( $result['ok'] ) ) {
720 + if ( ! in_array( (string) $slug, self::OPTIONAL_FOR_HANDOVER, true ) ) {
721 + return false;
722 + }
723 + continue;
724 + }
725 + if ( ! empty( $result['applied'] ) ) {
726 + $applied = true;
727 + }
728 + }
729 +
730 + return $applied;
731 + }
732 +
733 + /**
734 + * Install the object-cache drop-in for a just-imported backend, and fold
735 + * the outcome into that module's result row.
736 + *
737 + * A failure here is reported, never silent: the import has already told
738 + * the user their object cache came across.
739 + *
740 + * @param array $result The module's result row so far.
741 + * @return array The row, with ok/message reflecting the drop-in install.
742 + */
743 + private static function enable_object_cache( array $result ): array {
744 + if ( ! class_exists( __NAMESPACE__ . '\\Object_Cache' ) ) {
745 + return $result;
746 + }
747 +
748 + $opts = (array) Settings_Manager::get( 'object-cache' );
749 + $state = Object_Cache::enable( $opts );
750 +
751 + $result['ok'] = ! empty( $state['ok'] );
752 + $result['object_cache_ready'] = ! empty( $state['ok'] );
753 + if ( empty( $state['ok'] ) ) {
754 + // Surfaced by the panel instead of a green "imported" message.
755 + $result['message'] = (string) ( $state['message'] ?? 'Could not enable the object cache.' );
756 + }
757 +
758 + return $result;
759 + }
760 +
256 761 // ─────────────────────────── WP Rocket ───────────────────────────
257 762
258 763 public static function detect_wp_rocket(): ?array {
259 764 $opt = get_option( 'wp_rocket_settings', null );
@@ -269,10 +774,19 @@
269 774 */
270 775 public static function plan_wp_rocket( array $r ): array {
271 776 $patch = array();
272 777 // Page caching.
778 + // WP Rocket has no master on/off switch — installing and activating it
779 + // IS enabling page caching, so a detected settings blob means the
780 + // source site was caching. `cache_logged_user` is NOT that switch: it
781 + // controls whether LOGGED-IN users get cached pages. Reading it as the
782 + // master meant the most ordinary WP Rocket configuration of all —
783 + // caching on, but not for logged-in users (`cache_logged_user = 0`) —
784 + // imported as caching OFF, the exact inverse of the user's intent. The
785 + // `! isset` fallback then made a missing key mean ON, so the result was
786 + // right only by accident. (#222 F2)
273 787 $patch['cache'] = array(
274 - 'enabled' => ! empty( $r['cache_logged_user'] ) || ! isset( $r['cache_logged_user'] ),
788 + 'enabled' => true,
275 789 // The Cache module's TTL setting is `cache_expiry` (hours), NOT
276 790 // `expiry_hours` — the latter is a dead key nothing reads, so the
277 791 // imported lifetime was silently dropped. Clamp to the same 1–720h
278 792 // range the Cache schema + LiteSpeed importer use. (FBS-83144)
@@ -393,46 +907,278 @@
393 907 $patch['cache'] = array(
394 908 'enabled' => ! empty( $r['pgcache.enabled'] ),
395 909 // Cache module reads `cache_expiry` (hours), not the dead
396 910 // `expiry_hours` key — see plan_wp_rocket. (FBS-83144)
397 - 'cache_expiry' => isset( $r['pgcache.lifetime'] ) ? max( 1, min( 720, (int) ( (int) $r['pgcache.lifetime'] / 3600 ) ) ) : 24,
911 + // W3TC stores this in SECONDS and sub-hour values are common (900 /
912 + // 1800 are its own defaults). Integer division floored those to 0
913 + // and max(1, …) then bumped them to a full hour, so a 5-minute
914 + // lifetime silently became 12x longer. xSpeed's cache_expiry is
915 + // hour-granular, so the closest honest answer is to round to
916 + // nearest and keep the 1-hour floor for anything under 30 minutes.
917 + // preview_notes() tells the user when the source value could not be
918 + // represented exactly. (#218)
919 + 'cache_expiry' => isset( $r['pgcache.lifetime'] ) ? self::seconds_to_hours( (int) $r['pgcache.lifetime'] ) : 24,
398 920 );
399 - if ( ! empty( $r['pgcache.reject.uri'] ) && is_array( $r['pgcache.reject.uri'] ) ) {
400 - $patch['cache']['excluded_urls'] = array_values( array_filter( array_map( 'strval', $r['pgcache.reject.uri'] ) ) );
921 + $w3_list = static function ( $key ) use ( $r ): array {
922 + $v = $r[ $key ] ?? null;
923 + return is_array( $v ) ? array_values( array_filter( array_map( 'strval', $v ) ) ) : array();
924 + };
925 +
926 + foreach ( array(
927 + 'pgcache.reject.uri' => 'excluded_urls',
928 + 'pgcache.reject.cookie' => 'excluded_cookies',
929 + 'pgcache.reject.ua' => 'bypass_user_agents',
930 + ) as $src => $dest ) {
931 + $vals = $w3_list( $src );
932 + if ( $vals ) {
933 + $patch['cache'][ $dest ] = $vals;
934 + }
401 935 }
402 936
937 + // `pgcache.accept.qs` is deliberately NOT imported. W3TC ships ~100
938 + // tracking parameters in it by default, so importing it wholesale
939 + // would bury the user's own additions under a stock list — and
940 + // apply() unions lists, which would make that permanent. Our own
941 + // ignored_query_params default already covers the same ground.
942 + // (#218)
943 +
944 + self::map_mobile_separate( $patch, ! empty( $r['mobile.enabled'] ) );
945 +
946 + // W3TC's minify "method" encodes BOTH operations in one value:
947 + // 'minify' | 'combine' | 'both'. Combining is on for the latter two.
948 + $method_combines = static function ( $key ) use ( $r ): bool {
949 + $m = isset( $r[ $key ] ) ? (string) $r[ $key ] : '';
950 + return 'combine' === $m || 'both' === $m;
951 + };
952 +
953 + // The master switch gates every child mapping.
954 + //
955 + // W3TC keeps its child defaults POPULATED while `minify.enabled` is
956 + // off — `minify.css.enable`, `minify.js.enable` and the method fields
957 + // all read as truthy on a site that has minification deliberately
958 + // switched off. Reading the children alone therefore imported Minify
959 + // CSS/JS and Combine CSS/JS as ON for a user who had turned the whole
960 + // feature off, which can change front-end output and introduce the
961 + // exact CSS/JS regressions migration is supposed to avoid.
962 + //
963 + // Every other W3TC block here already gates this way — pgcache on
964 + // `pgcache.enabled`, lazy load on `lazyload.enabled`, browser cache on
965 + // `browsercache.enabled`, object cache on `objectcache.enabled`.
966 + // Minify was the one that did not. (#218)
967 + $minify_on = ! empty( $r['minify.enabled'] );
968 +
403 969 $patch['minify'] = array(
404 - 'minify_html' => ! empty( $r['minify.html.enable'] ),
405 - 'minify_css' => ! empty( $r['minify.css.enable'] ),
406 - 'minify_js' => ! empty( $r['minify.js.enable'] ),
970 + 'minify_html' => $minify_on && ! empty( $r['minify.html.enable'] ),
971 + 'minify_css' => $minify_on && ! empty( $r['minify.css.enable'] ),
972 + 'minify_js' => $minify_on && ! empty( $r['minify.js.enable'] ),
973 + // There is no `minify.css.combine`; CSS combining lives in the
974 + // method. JS splits its combine flag across three placements, and
975 + // any one of them means the user wanted combining.
976 + 'combine_css' => $minify_on && $method_combines( 'minify.css.method' ),
977 + 'combine_js' => $minify_on && (
978 + $method_combines( 'minify.js.method' )
979 + || ! empty( $r['minify.js.combine.header'] )
980 + || ! empty( $r['minify.js.combine.body'] )
981 + || ! empty( $r['minify.js.combine.footer'] )
982 + ),
407 983 );
408 984
985 + // ── Lazy load ────────────────────────────────────────────────────
986 + if ( ! empty( $r['lazyload.enabled'] ) ) {
987 + $patch['lazy'] = array( 'lazy_images' => true );
988 + $excluded = $w3_list( 'lazyload.exclude' );
989 + if ( $excluded ) {
990 + $patch['lazy']['excluded_images'] = $excluded;
991 + }
992 + }
993 +
994 + // ── Browser cache + compression ──────────────────────────────────
995 + if ( ! empty( $r['browsercache.enabled'] ) ) {
996 + $patch['browser-cache'] = array( 'enabled' => true );
997 + foreach ( array(
998 + 'browsercache.cssjs.lifetime' => 'asset_ttl',
999 + 'browsercache.html.lifetime' => 'html_ttl',
1000 + ) as $src => $dest ) {
1001 + if ( ! empty( $r[ $src ] ) ) {
1002 + $patch['browser-cache'][ $dest ] = (int) $r[ $src ];
1003 + }
1004 + }
1005 + // W3TC has a compression toggle per content type; xSpeed has one
1006 + // switch, so any of them being on means the user wanted GZIP.
1007 + if ( ! empty( $r['browsercache.html.compression'] ) || ! empty( $r['browsercache.cssjs.compression'] ) || ! empty( $r['browsercache.other.compression'] ) ) {
1008 + $patch['gzip'] = array( 'gzip_enabled' => true );
1009 + }
1010 + }
1011 +
1012 + // ── Preloader (W3TC calls it "cache priming") ────────────────────
1013 + if ( ! empty( $r['pgcache.prime.enabled'] ) ) {
1014 + $patch['preloader'] = array( 'enabled' => true );
1015 + if ( ! empty( $r['pgcache.prime.sitemap'] ) ) {
1016 + $patch['preloader']['sitemap_url'] = (string) $r['pgcache.prime.sitemap'];
1017 + }
1018 + if ( ! empty( $r['pgcache.prime.interval'] ) ) {
1019 + $patch['preloader']['schedule'] = self::seconds_to_schedule( (int) $r['pgcache.prime.interval'] );
1020 + }
1021 + if ( ! empty( $r['pgcache.prime.post.update.enabled'] ) ) {
1022 + $patch['preloader']['warm_on_publish'] = true;
1023 + }
1024 + }
1025 +
1026 + // ── Bloat ────────────────────────────────────────────────────────
1027 + if ( ! empty( $r['jquerymigrate.disabled'] ) ) {
1028 + $patch['bloat'] = array( 'strip_jquery_migrate' => true );
1029 + }
1030 +
409 1031 if ( ! empty( $r['objectcache.enabled'] ) && ! empty( $r['objectcache.engine'] ) ) {
410 - $patch['object-cache'] = array(
411 - 'backend' => 'memcached' === $r['objectcache.engine'] ? 'memcached' : 'redis',
412 - );
413 - if ( ! empty( $r['objectcache.servers'] ) && is_array( $r['objectcache.servers'] ) ) {
414 - $first = (string) ( $r['objectcache.servers'][0] ?? '' );
415 - if ( false !== strpos( $first, ':' ) ) {
416 - [ $host, $port ] = explode( ':', $first, 2 );
417 - if ( 'redis' === ( $patch['object-cache']['backend'] ?? '' ) ) {
418 - $patch['object-cache']['redis_host'] = $host;
419 - $patch['object-cache']['redis_port'] = (int) $port;
420 - } else {
421 - $patch['object-cache']['memcached_host'] = $host;
422 - $patch['object-cache']['memcached_port'] = (int) $port;
1032 + $is_memcached = 'memcached' === $r['objectcache.engine'];
1033 + $engine = $is_memcached ? 'memcached' : 'redis';
1034 +
1035 + $patch['object-cache'] = array( 'backend' => $engine );
1036 +
1037 + // W3TC namespaces these by engine — `objectcache.redis.servers` /
1038 + // `objectcache.memcached.servers`. There is no bare
1039 + // `objectcache.servers`, so the host/port branch here could never
1040 + // run for a W3TC source: a site with Redis on a non-default host or
1041 + // port silently fell back to 127.0.0.1:6379. (#218)
1042 + $servers = $r[ 'objectcache.' . $engine . '.servers' ] ?? null;
1043 + if ( ! empty( $servers ) && is_array( $servers ) ) {
1044 + $first = (string) ( $servers[0] ?? '' );
1045 +
1046 + // A scheme says HOW to connect, not WHERE. Left in place it
1047 + // becomes the hostname: Redis_Client::connect() builds
1048 + // "tcp://{$host}:{$port}", so `tls://redis.example` produces
1049 + // `tcp://tls://redis.example:6380` and resolution fails on the
1050 + // literal host "tls". The same string is also written into the
1051 + // drop-in's WP_REDIS_HOST, so the imported object cache could
1052 + // never connect. A managed Redis on TLS is the common case.
1053 + // Bracketed IPv6 is left alone — stream_socket_client() wants
1054 + // the brackets. (#224)
1055 + $first = (string) preg_replace( '#^[a-z][a-z0-9+.-]*://#i', '', $first );
1056 + $separator = strrpos( $first, ':' );
1057 + if ( false !== $separator ) {
1058 + $host = substr( $first, 0, $separator );
1059 + $port = substr( $first, $separator + 1 );
1060 + $patch['object-cache'][ $engine . '_host' ] = $host;
1061 + $patch['object-cache'][ $engine . '_port' ] = (int) $port;
1062 + }
1063 + }
1064 +
1065 + // Everything else that has a destination in ObjectCacheModule and
1066 + // was previously dropped. A non-zero Redis DB index matters most:
1067 + // the connection would succeed while pointing at the wrong dataset.
1068 + $copy = $is_memcached
1069 + ? array( 'objectcache.memcached.persistent' => 'persistent' )
1070 + : array(
1071 + 'objectcache.redis.dbid' => 'redis_database',
1072 + 'objectcache.redis.password' => 'redis_password',
1073 + 'objectcache.redis.persistent' => 'persistent',
1074 + 'objectcache.redis.timeout' => 'connection_timeout',
1075 + );
1076 + $numeric_dests = array( 'redis_database', 'connection_timeout' );
1077 + foreach ( $copy as $src => $dest ) {
1078 + if ( ! isset( $r[ $src ] ) || '' === $r[ $src ] ) {
1079 + continue;
1080 + }
1081 + // W3TC stores an unset timeout / db index as 0, which carries no
1082 + // intent — emitting it would put a meaningless value in the plan
1083 + // preview the user is asked to confirm. Only the numeric fields
1084 + // get that treatment: casting a password to int would read
1085 + // "s3cret" as 0 and silently drop it.
1086 + $is_numeric_dest = in_array( $dest, $numeric_dests, true );
1087 + if ( $is_numeric_dest && 0 === (int) $r[ $src ] ) {
1088 + continue;
1089 + }
1090 + // W3 Total Cache 2.8+ encrypts secrets in its config file
1091 + // (Util_Crypto, `enc:v1:` prefix). Copying the ciphertext
1092 + // through would hand Redis a password that can never
1093 + // authenticate — and it is exactly the password-protected
1094 + // sources this mapping exists to serve. Decrypt with W3TC's
1095 + // own helper; if that is unavailable (no crypto key, plugin
1096 + // files already gone), SKIP the field rather than import an
1097 + // unusable value: a missing password is something the user
1098 + // can fix in one edit, a silently wrong one is not. (#224 F1)
1099 + if ( 'redis_password' === $dest ) {
1100 + $secret = self::decrypt_w3tc_secret( (string) $r[ $src ] );
1101 + if ( null === $secret ) {
1102 + continue;
423 1103 }
1104 + $patch['object-cache'][ $dest ] = $secret;
1105 + continue;
424 1106 }
1107 + $patch['object-cache'][ $dest ] = $is_numeric_dest
1108 + ? (int) $r[ $src ]
1109 + : $r[ $src ];
425 1110 }
1111 + // W3TC's Cache_Redis only ever calls auth( $password ) — it has no
1112 + // ACL username support — so a W3TC source never carries one and we
1113 + // must not invent a redis_user here.
426 1114 }
427 1115
428 - if ( ! empty( $r['browsercache.enabled'] ) ) {
429 - $patch['browser-cache'] = array(
430 - 'enabled' => true,
431 - );
1116 + return $patch;
1117 + }
1118 +
1119 + /**
1120 + * Resolve a W3 Total Cache secret to plaintext.
1121 + *
1122 + * W3TC 2.8+ stores secrets encrypted with its own `Util_Crypto`, marked
1123 + * by an `enc:v1:` prefix. A plaintext value (older W3TC, or a config
1124 + * written before encryption landed) is returned unchanged.
1125 + *
1126 + * Returns null when the value is encrypted but cannot be decrypted —
1127 + * W3TC's classes are not loadable, or its crypto key is gone. Callers
1128 + * MUST treat null as "skip this field", never as an empty password:
1129 + * importing the ciphertext guarantees an auth failure, and importing an
1130 + * empty string would silently drop a password the source really had.
1131 + *
1132 + * @param string $value Raw value from the W3TC config.
1133 + * @return string|null Plaintext, or null when it cannot be resolved.
1134 + */
1135 + private static function decrypt_w3tc_secret( string $value ): ?string {
1136 + if ( 0 !== strpos( $value, 'enc:' ) ) {
1137 + return $value;
432 1138 }
433 1139
434 - return $patch;
1140 + if ( ! class_exists( '\\W3TC\\Util_Crypto' ) ) {
1141 + return null;
1142 + }
1143 +
1144 + // W3TC's method is envelope_decrypt(), NOT decrypt(). Guarding on the
1145 + // wrong name meant method_exists() was false on every install, the
1146 + // helper returned null before it ever ran, and the password was
1147 + // silently dropped from every import — the exact users the decrypt
1148 + // support was written for. Verified against W3TC 2.10.5:
1149 + //
1150 + // ::decrypt() MISSING
1151 + // ::envelope_decrypt() EXISTS
1152 + // ::is_envelope() EXISTS
1153 + //
1154 + // Kept as a list so an older/newer W3TC that renames it again
1155 + // degrades to "skip the field" rather than to a fatal. (#218 F1)
1156 + $method = null;
1157 + foreach ( array( 'envelope_decrypt', 'decrypt' ) as $candidate ) {
1158 + if ( method_exists( '\\W3TC\\Util_Crypto', $candidate ) ) {
1159 + $method = $candidate;
1160 + break;
1161 + }
1162 + }
1163 + if ( null === $method ) {
1164 + return null;
1165 + }
1166 +
1167 + try {
1168 + $plain = \W3TC\Util_Crypto::$method( $value );
1169 + } catch ( \Throwable $e ) {
1170 + return null;
1171 + }
1172 +
1173 + // A failed decrypt can come back as false/null/'' or as the
1174 + // untouched ciphertext depending on the failure mode. None of those
1175 + // are a usable password.
1176 + if ( ! is_string( $plain ) || '' === $plain || 0 === strpos( $plain, 'enc:' ) ) {
1177 + return null;
1178 + }
1179 +
1180 + return $plain;
435 1181 }
436 1182
437 1183 // ─────────────────────────── WP Super Cache ──────────────────────
438 1184
@@ -449,9 +1195,9 @@
449 1195 return $cfg;
450 1196 }
451 1197 if ( self::plugin_active( 'wp-super-cache/wp-cache.php' ) ) {
452 1198 // Active but unconfigured — expose the on/off intent only.
453 - return array( 'wp_cache_enabled' => defined( 'WPCACHEHOME' ) );
1199 + return array( 'cache_enabled' => defined( 'WPCACHEHOME' ) );
454 1200 }
455 1201 return null;
456 1202 }
457 1203
@@ -474,17 +1220,34 @@
474 1220 $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.
475 1221 if ( false === $raw || '' === $raw ) {
476 1222 return null;
477 1223 }
478 - $out = array();
479 - $keys = array( 'wp_cache_enabled', 'wp_cache_mod_rewrite', 'wp_cache_mobile_enabled', 'wp_cache_not_logged_in', 'wp_cache_make_known_anon' );
1224 + $out = array();
1225 +
1226 + // `$cache_enabled` is the master switch and `$super_cache_enabled`
1227 + // selects mod_rewrite mode. WP Super Cache does NOT define
1228 + // `$wp_cache_enabled` — reading that name meant the on/off intent was
1229 + // never populated, plan_wpsc() computed `enabled => false`, and
1230 + // meaningful_values() then dropped the false boolean entirely. A site
1231 + // actively serving cached HTML migrated to caching OFF, silently. (#219)
1232 + $keys = array( 'cache_enabled', 'super_cache_enabled', 'wp_cache_mod_rewrite', 'wp_cache_mobile_enabled', 'wp_cache_make_known_anon' );
480 1233 foreach ( $keys as $key ) {
481 1234 // Match `$key = 1;` / `$key = '1';` / `$key = true;` etc.
482 1235 if ( preg_match( '/\$' . preg_quote( $key, '/' ) . '\s*=\s*([^;]+);/', $raw, $m ) ) {
483 - $val = trim( $m[1], " \t'\"" );
1236 + $val = trim( $m[1], " \t'\"" );
484 1237 $out[ $key ] = in_array( strtolower( $val ), array( '1', 'true' ), true );
485 1238 }
486 1239 }
1240 +
1241 + // Tri-state, so it cannot go through the boolean cast above:
1242 + // 0 = cache everyone, 1 = skip visitors carrying any cookie,
1243 + // 2 = skip logged-in visitors (WPSC's own recommended setting).
1244 + // Casting collapsed 2 to false — the exact inverse of the user's
1245 + // intent — which is harmless only while nothing maps the key. (#219)
1246 + if ( preg_match( '/\$wp_cache_not_logged_in\s*=\s*([^;]+);/', $raw, $m ) ) {
1247 + $out['wp_cache_not_logged_in'] = (int) trim( $m[1], " \t'\"" );
1248 + }
1249 +
487 1250 return ! empty( $out ) ? $out : null;
488 1251 }
489 1252
490 1253 /** Thin wrapper so detection works before admin plugin.php is loaded. */
@@ -500,9 +1263,12 @@
500 1263
501 1264 public static function plan_wpsc( array $r ): array {
502 1265 $plan = array(
503 1266 'cache' => array(
504 - 'enabled' => ! empty( $r['wp_cache_enabled'] ),
1267 + // Either flag means WP Super Cache was serving: `cache_enabled`
1268 + // is the master switch, `super_cache_enabled` only picks
1269 + // mod_rewrite over PHP delivery.
1270 + 'enabled' => ! empty( $r['cache_enabled'] ) || ! empty( $r['super_cache_enabled'] ),
505 1271 ),
506 1272 );
507 1273 // See plan_wp_rocket: never import Separate Mobile Cache as ON — it
508 1274 // disables the device-blind static fast path. Flag for review instead.
@@ -545,11 +1311,9 @@
545 1311 $name = substr( (string) $row['option_name'], strlen( 'litespeed.conf.' ) );
546 1312 if ( '' === $name || '_version' === $name ) {
547 1313 continue;
548 1314 }
549 - // option_value is stored serialized by WP; maybe_unserialize
550 - // gives back arrays (list settings) or scalars as-is.
551 - $conf[ $name ] = maybe_unserialize( $row['option_value'] );
1315 + $conf[ $name ] = self::decode_litespeed_value( (string) $row['option_value'] );
552 1316 }
553 1317 if ( ! empty( $conf ) ) {
554 1318 return $conf;
555 1319 }
@@ -563,8 +1327,34 @@
563 1327 return is_array( $opt ) && ! empty( $opt ) ? $opt : null;
564 1328 }
565 1329
566 1330 /**
1331 + * Decode one `litespeed.conf.*` option value.
1332 + *
1333 + * LiteSpeed v4+ stores its list settings as JSON strings, not
1334 + * PHP-serialized arrays, so maybe_unserialize() hands the JSON straight
1335 + * back as a string. plan_litespeed()'s $list() helper then splits it on
1336 + * newlines — which JSON has none of — producing a ONE-element array
1337 + * holding the entire blob. Every exclusion rule imported that way is
1338 + * dead: the list no longer matches anything, so cart, checkout and
1339 + * account pages become publicly cacheable while the import reports
1340 + * success. (#217)
1341 + *
1342 + * Try JSON first for anything shaped like it, and fall back to
1343 + * maybe_unserialize() so v3 / legacy installs keep working.
1344 + */
1345 + private static function decode_litespeed_value( string $raw ) {
1346 + $trimmed = trim( $raw );
1347 + if ( '' !== $trimmed && ( '[' === $trimmed[0] || '{' === $trimmed[0] ) ) {
1348 + $decoded = json_decode( $trimmed, true );
1349 + if ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) ) {
1350 + return $decoded;
1351 + }
1352 + }
1353 + return maybe_unserialize( $raw );
1354 + }
1355 +
1356 + /**
567 1357 * Translate LiteSpeed's `litespeed.conf` into xSpeed module patches.
568 1358 * LiteSpeed uses dotted keys; values are mostly bool-ish (1/0/'1').
569 1359 * We map only the settings that have a clean xSpeed equivalent and
570 1360 * leave the rest untouched so nothing is silently mis-imported.
@@ -586,9 +1376,25 @@
586 1376 return array();
587 1377 }
588 1378 return array_values( array_filter( array_map( 'trim', array_map( 'strval', $v ) ) ) );
589 1379 };
590 - $set_list = static function ( array &$dest, string $dest_key, array $vals ): void {
1380 + // LiteSpeed writes a regex rule bare (`^/secret-.*`); xSpeed marks one
1381 + // with a leading `~` (see CacheModule's own `~wp-.*\.php` default) and
1382 + // treats anything else as a literal/glob. Imported unchanged, a
1383 + // LiteSpeed regex became a literal that matches nothing — the same
1384 + // silent loss of protection as the JSON bug above, just narrower. Only
1385 + // rules carrying an unmistakable regex metacharacter are converted, so
1386 + // a plain path like `/cart` stays the literal it already is. (#217)
1387 + $to_xspeed_pattern = static function ( string $rule ): string {
1388 + if ( '' === $rule || '~' === $rule[0] ) {
1389 + return $rule;
1390 + }
1391 + return preg_match( '/[\^$|]|\.\*|\.\+|\[.+\]|\\\\[dwsb]/', $rule ) ? '~' . $rule : $rule;
1392 + };
1393 + $set_list = static function ( array &$dest, string $dest_key, array $vals ) use ( $to_xspeed_pattern ): void {
1394 + if ( in_array( $dest_key, array( 'excluded_urls', 'excluded_patterns' ), true ) ) {
1395 + $vals = array_map( $to_xspeed_pattern, $vals );
1396 + }
591 1397 if ( $vals ) {
592 1398 $dest[ $dest_key ] = $vals;
593 1399 }
594 1400 };
@@ -608,9 +1414,12 @@
608 1414 }
609 1415 // Excluded URIs / cookies / user-agents / dropped query strings.
610 1416 $set_list( $patch['cache'], 'excluded_urls', $list( 'cache-exc' ) );
611 1417 $set_list( $patch['cache'], 'excluded_cookies', $list( 'cache-exc_cookies' ) );
612 - $set_list( $patch['cache'], 'bypass_user_agents', $list( 'cache-exc_useragent' ) );
1418 + // `cache-exc_useragents`, plural — LiteSpeed's O_CACHE_EXC_USERAGENTS.
1419 + // The singular spelling matched nothing, so the list always imported
1420 + // empty, and an empty field looks "not configured" rather than lost. (#217)
1421 + $set_list( $patch['cache'], 'bypass_user_agents', $list( 'cache-exc_useragents' ) );
613 1422 // LiteSpeed "drop query string" list ≈ xSpeed ignored_query_params.
614 1423 $set_list( $patch['cache'], 'ignored_query_params', $list( 'cache-drop_qs' ) );
615 1424
616 1425 // ── Minify / optimization ─────────────────────────────────────
@@ -619,12 +1428,16 @@
619 1428 'minify_css' => $on( 'optm-css_min' ),
620 1429 'minify_js' => $on( 'optm-js_min' ),
621 1430 'combine_css' => $on( 'optm-css_comb' ),
622 1431 'combine_js' => $on( 'optm-js_comb' ),
623 - 'defer_js' => $on( 'optm-js_defer' ),
624 - // LiteSpeed "Delay JS" (optm-js_defer === 2 in some versions, or
625 - // the dedicated optm-js_delay flag) → xSpeed delay_js.
626 - 'delay_js' => $on( 'optm-js_delay' ) || ( isset( $r['optm-js_defer'] ) && (int) $r['optm-js_defer'] === 2 ),
1432 + // optm-js_defer is a THREE-WAY switch, not a boolean:
1433 + // 0 = OFF, 1 = Deferred, 2 = Delayed (LiteSpeed's own UI labels,
1434 + // tpl/page_optm/settings_js.tpl.php). The modes replace each other,
1435 + // so 2 must set delay_js INSTEAD of defer_js — the old mapping set
1436 + // both, turning one LiteSpeed choice into two xSpeed transforms
1437 + // that fight each other. (#217)
1438 + 'defer_js' => isset( $r['optm-js_defer'] ) && 1 === (int) $r['optm-js_defer'],
1439 + 'delay_js' => isset( $r['optm-js_defer'] ) && 2 === (int) $r['optm-js_defer'],
627 1440 // Async/“load CSS asynchronously” — LiteSpeed CCSS async.
628 1441 'async_css' => $on( 'optm-css_async' ),
629 1442 // Remove query strings from static resources.
630 1443 'remove_query_strings' => $on( 'optm-qs_rm' ),
@@ -630,10 +1443,16 @@
630 1443 'remove_query_strings' => $on( 'optm-qs_rm' ),
631 1444 );
632 1445 // Defer/delay exclusion list — merge LiteSpeed's JS defer + delay
633 1446 // exclude lists into xSpeed's single defer_js_excluded.
634 - $defer_exc = array_values( array_unique( array_merge( $list( 'optm-js_defer_exc' ), $list( 'optm-js_delay_exc' ) ) ) );
1447 + // `optm-js_delay_exc` does not exist in LiteSpeed. Its delay list is
1448 + // optm-js_delay_inc (O_OPTM_JS_DELAY_INC) — an INCLUDE list naming the
1449 + // scripts to delay, which is xSpeed's delay_js_targets, not an
1450 + // exclusion. Merging it into defer_js_excluded would have inverted the
1451 + // user's intent, so it maps to its own destination below. (#217)
1452 + $defer_exc = $list( 'optm-js_defer_exc' );
635 1453 $set_list( $patch['minify'], 'defer_js_excluded', $defer_exc );
1454 + $set_list( $patch['minify'], 'delay_js_targets', $list( 'optm-js_delay_inc' ) );
636 1455
637 1456 // ── Lazy load (media) ─────────────────────────────────────────
638 1457 $patch['lazy'] = array(
639 1458 'lazy_images' => $on( 'media-lazy' ),
@@ -716,9 +1535,10 @@
716 1535 $patch['cdn'] = array(
717 1536 'enabled' => true,
718 1537 'cdn_url' => $cdn_url,
719 1538 );
720 - $set_list( $patch['cdn'], 'excluded_patterns', $list( 'cdn-exclude' ) );
1539 + // LiteSpeed's O_CDN_EXC is `cdn-exc`, not `cdn-exclude`. (#217)
1540 + $set_list( $patch['cdn'], 'excluded_patterns', $list( 'cdn-exc' ) );
721 1541 }
722 1542 }
723 1543
724 1544 return $patch;