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
xspeed / includes / class-migration.php

class-migration.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.2, at includes/class-migration.php

1,576 lines 64.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Migration — read settings from other popular caching plugins and
4 * translate them into xSpeed equivalents.
5 *
6 * Each source plugin has its own importer that returns a single
7 * normalized patch shape:
8 *
9 * array<string,array> // module-slug → settings patch
10 *
11 * which feeds straight into update_option('xspeed_module_<slug>')
12 * via the same write path the Recommendations module uses.
13 *
14 * Importers are pure: detect() reads the options table, returns
15 * what it found (or null if the source plugin's options aren't
16 * present). plan() turns that raw read into the patch. apply()
17 * writes it. preview() returns plan() without writing — used by
18 * the React panel for the "what would import" diff view.
19 *
20 * Adding a new source = adding a private detect_*() + plan_*()
21 * pair, then wiring them in `sources()`.
22 *
23 * @package XSpeed
24 */
25
26 declare(strict_types=1);
27
28 namespace XSpeed;
29
30 defined( 'ABSPATH' ) || exit;
31
32 final class Migration {
33
34 /**
35 * Public list of source plugins with metadata for the UI:
36 * [ id => [ label, detect_cb, plan_cb ] ]
37 */
38 public static function sources(): array {
39 return array(
40 'wp-rocket' => array(
41 'label' => 'WP Rocket',
42 'detect' => array( __CLASS__, 'detect_wp_rocket' ),
43 'plan' => array( __CLASS__, 'plan_wp_rocket' ),
44 ),
45 'w3-total-cache' => array(
46 'label' => 'W3 Total Cache',
47 'detect' => array( __CLASS__, 'detect_w3tc' ),
48 'plan' => array( __CLASS__, 'plan_w3tc' ),
49 ),
50 'wp-super-cache' => array(
51 'label' => 'WP Super Cache',
52 'detect' => array( __CLASS__, 'detect_wpsc' ),
53 'plan' => array( __CLASS__, 'plan_wpsc' ),
54 ),
55 'litespeed-cache' => array(
56 'label' => 'LiteSpeed Cache',
57 'detect' => array( __CLASS__, 'detect_litespeed' ),
58 'plan' => array( __CLASS__, 'plan_litespeed' ),
59 ),
60 );
61 }
62
63 /** Site option holding the list of source ids already imported. */
64 private const IMPORTED_OPTION = 'xspeed_migration_imported';
65
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(
79 'wp-rocket' => 'wp-rocket/wp-rocket.php',
80 'w3-total-cache' => 'w3-total-cache/w3-total-cache.php',
81 'wp-super-cache' => 'wp-super-cache/wp-cache.php',
82 'litespeed-cache' => 'litespeed-cache/litespeed-cache.php',
83 );
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
177 /** Record a source as imported (idempotent). */
178 public static function mark_imported( string $id ): void {
179 $done = (array) get_option( self::IMPORTED_OPTION, array() );
180 if ( ! in_array( $id, $done, true ) ) {
181 $done[] = $id;
182 update_option( self::IMPORTED_OPTION, array_values( $done ) );
183 }
184 }
185
186 /**
187 * For each source, return { id, label, detected, value_count, mapped_count,
188 * imported, active }.
189 * - `detected` true when the source plugin's settings are present.
190 * - `value_count` raw number of keys in the source's own config — NOT
191 * how many we import; kept for diagnostics only.
192 * - `mapped_count` how many settings the importer ACTUALLY writes into
193 * xSpeed (the honest number to show users).
194 * - `imported` true once this source has been imported (so the panel
195 * shows it as done, not as a fresh Import target).
196 * - `active` whether the source plugin is still active.
197 */
198 public static function status(): array {
199 $imported = (array) get_option( self::IMPORTED_OPTION, array() );
200 if ( ! function_exists( 'is_plugin_active' ) ) {
201 require_once ABSPATH . 'wp-admin/includes/plugin.php';
202 }
203 $out = array();
204 foreach ( self::sources() as $id => $spec ) {
205 $raw = call_user_func( $spec['detect'] );
206 $detected = is_array( $raw );
207 $mapped = 0;
208 if ( $detected ) {
209 $patch = call_user_func( $spec['plan'], $raw );
210 if ( is_array( $patch ) ) {
211 $mapped = self::count_meaningful( $patch );
212 }
213 }
214 $file = self::PLUGIN_FILE[ $id ] ?? '';
215 $out[] = array(
216 'id' => $id,
217 'label' => $spec['label'],
218 'detected' => $detected,
219 'value_count' => $detected ? count( $raw ) : 0,
220 'mapped_count' => $mapped,
221 'imported' => in_array( $id, $imported, true ),
222 'active' => '' !== $file && is_plugin_active( $file ),
223 );
224 }
225 return $out;
226 }
227
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 /**
437 * Return the patch that `apply()` would write, without writing.
438 */
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 {
459 $src = self::sources()[ $source_id ] ?? null;
460 if ( null === $src ) {
461 return null;
462 }
463 $raw = call_user_func( $src['detect'] );
464 if ( ! is_array( $raw ) ) {
465 return null;
466 }
467
468 return array(
469 'patch' => call_user_func( $src['plan'], $raw ),
470 'notes' => self::preview_notes( $source_id, $raw ),
471 );
472 }
473
474 /**
475 * Read source settings + write the translated patch. Returns the
476 * per-module write results, same shape as Recommendations::apply.
477 */
478 public static function apply( string $source_id ): array {
479 $patch = self::preview( $source_id );
480 if ( null === $patch ) {
481 return array();
482 }
483 $results = array();
484 foreach ( $patch as $slug => $values ) {
485 if ( ! is_string( $slug ) || ! is_array( $values ) ) {
486 continue;
487 }
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
610 $results[ $slug ] = array(
611 'ok' => $ok,
612 'applied' => $applied,
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 }
630 }
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 ) ) {
637 self::mark_imported( $source_id );
638 }
639 return $results;
640 }
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
769 // ─────────────────────────── WP Rocket ───────────────────────────
770
771 public static function detect_wp_rocket(): ?array {
772 $opt = get_option( 'wp_rocket_settings', null );
773 return is_array( $opt ) ? $opt : null;
774 }
775
776 /**
777 * Translate WP Rocket's `wp_rocket_settings` array into our module
778 * settings. Only safe-to-port booleans + counts; behaviorally
779 * different toggles (Critical CSS, RUCSS) skip — Pro handles those.
780 *
781 * @param array $r raw wp_rocket_settings.
782 */
783 public static function plan_wp_rocket( array $r ): array {
784 $patch = array();
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)
795 $patch['cache'] = array(
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,
802 );
803 // Excluded URLs / cookies — both are arrays of strings in WP Rocket.
804 if ( ! empty( $r['cache_reject_uri'] ) && is_array( $r['cache_reject_uri'] ) ) {
805 $patch['cache']['excluded_urls'] = array_values( array_filter( array_map( 'strval', $r['cache_reject_uri'] ) ) );
806 }
807 if ( ! empty( $r['cache_reject_cookies'] ) && is_array( $r['cache_reject_cookies'] ) ) {
808 $patch['cache']['excluded_cookies'] = array_values( array_filter( array_map( 'strval', $r['cache_reject_cookies'] ) ) );
809 }
810
811 // Minify.
812 $patch['minify'] = array(
813 'minify_html' => ! empty( $r['minify_html'] ),
814 'minify_css' => ! empty( $r['minify_css'] ),
815 'minify_js' => ! empty( $r['minify_js'] ),
816 'combine_css' => ! empty( $r['minify_concatenate_css'] ),
817 'combine_js' => ! empty( $r['minify_concatenate_js'] ),
818 'defer_js' => ! empty( $r['defer_all_js'] ),
819 );
820
821 // Lazy load.
822 $patch['lazy'] = array(
823 'lazy_images' => ! empty( $r['lazyload'] ),
824 'lazy_iframes' => ! empty( $r['lazyload_iframes'] ),
825 'lazy_videos' => ! empty( $r['lazyload_youtube'] ),
826 );
827 if ( ! empty( $r['lazyload_css_bg_img'] ) ) {
828 $patch['lazy']['lazy_background_images'] = true;
829 }
830
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'] ) );
840
841 // Preloader.
842 if ( ! empty( $r['manual_preload'] ) || ! empty( $r['sitemap_preload'] ) ) {
843 $patch['preloader'] = array(
844 'enabled' => true,
845 'schedule' => 'daily',
846 );
847 if ( ! empty( $r['sitemap_preload_url'] ) && is_array( $r['sitemap_preload_url'] ) ) {
848 $patch['preloader']['sitemap_urls'] = array_values( array_filter( array_map( 'strval', $r['sitemap_preload_url'] ) ) );
849 }
850 }
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
863 // CDN — WP Rocket stores CDN hosts in cdn_cnames (array).
864 if ( ! empty( $r['cdn'] ) && ! empty( $r['cdn_cnames'] ) && is_array( $r['cdn_cnames'] ) ) {
865 $first = (string) ( $r['cdn_cnames'][0] ?? '' );
866 if ( '' !== $first ) {
867 $patch['cdn'] = array(
868 'enabled' => true,
869 'cdn_url' => $first,
870 );
871 }
872 }
873
874 return $patch;
875 }
876
877 // ─────────────────────────── W3 Total Cache ──────────────────────
878
879 public static function detect_w3tc(): ?array {
880 // W3 Total Cache does NOT store its config in the options table — it
881 // writes a PHP file at wp-content/w3tc-config/master.php whose body
882 // is a short PHP guard followed by a JSON blob of dotted-key settings
883 // (pgcache.enabled, minify.html.enable, …). Reading w3tc_config /
884 // w3tc_master_settings options always returned null, so detection
885 // failed on every install. Read + parse the config file instead.
886 $cfg = self::read_w3tc_config_file();
887 if ( is_array( $cfg ) && ! empty( $cfg ) ) {
888 return $cfg;
889 }
890 // Defensive fallback for any build that did persist an options blob.
891 $opt = get_option( 'w3tc_config', null );
892 if ( ! is_array( $opt ) ) {
893 $opt = get_option( 'w3tc_master_settings', null );
894 }
895 return is_array( $opt ) ? $opt : null;
896 }
897
898 /**
899 * Parse W3TC's master config file into a flat dotted-key array.
900 * Format: a short PHP guard (a php-open, exit, php-close) immediately
901 * followed by a JSON object. We strip everything up to and including the
902 * PHP closing tag, then JSON-decode the remainder.
903 *
904 * @return array|null parsed config, or null if the file is missing/unreadable.
905 */
906 private static function read_w3tc_config_file(): ?array {
907 if ( ! defined( 'WP_CONTENT_DIR' ) ) {
908 return null;
909 }
910 $path = WP_CONTENT_DIR . '/w3tc-config/master.php';
911 if ( ! is_readable( $path ) ) {
912 return null;
913 }
914 $raw = file_get_contents( $path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading another plugin's local config file; WP_Filesystem is overkill for a one-shot read.
915 if ( false === $raw || '' === $raw ) {
916 return null;
917 }
918 // Drop the leading PHP guard and decode the JSON tail. The pattern
919 // matches up to the first PHP closing tag; built from a char-code so
920 // no literal close tag appears in this source file.
921 $close_tag = '?' . '>';
922 $json = preg_replace( '/^.*?' . preg_quote( $close_tag, '/' ) . '/s', '', $raw );
923 $cfg = json_decode( trim( (string) $json ), true );
924 return is_array( $cfg ) ? $cfg : null;
925 }
926
927 public static function plan_w3tc( array $r ): array {
928 $patch = array();
929 $patch['cache'] = array(
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,
942 );
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 }
957 }
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
991 $patch['minify'] = array(
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 ),
1005 );
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
1053 if ( ! empty( $r['objectcache.enabled'] ) && ! empty( $r['objectcache.engine'] ) ) {
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;
1133 }
1134 $patch['object-cache'][ $dest ] = $secret;
1135 continue;
1136 }
1137 $patch['object-cache'][ $dest ] = $is_numeric_dest
1138 ? (int) $r[ $src ]
1139 : $r[ $src ];
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.
1144 }
1145
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;
1168 }
1169
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;
1211 }
1212
1213 // ─────────────────────────── WP Super Cache ──────────────────────
1214
1215 public static function detect_wpsc(): ?array {
1216 // WP Super Cache stores its settings as PHP globals in
1217 // wp-content/wp-cache-config.php (NOT the options table — the old
1218 // get_option('wp_cache_enabled') reads always returned null). Parse
1219 // the config file for the globals plan_wpsc() needs. If the file
1220 // doesn't exist yet (plugin active but never configured), fall back
1221 // to a minimal "active" marker so the source still appears in the UI
1222 // and a default import is possible.
1223 $cfg = self::read_wpsc_config_file();
1224 if ( is_array( $cfg ) && ! empty( $cfg ) ) {
1225 return $cfg;
1226 }
1227 if ( self::plugin_active( 'wp-super-cache/wp-cache.php' ) ) {
1228 // Active but unconfigured — expose the on/off intent only.
1229 return array( 'cache_enabled' => defined( 'WPCACHEHOME' ) );
1230 }
1231 return null;
1232 }
1233
1234 /**
1235 * Parse the WP Super Cache config file for the globals we map. The file
1236 * is plain PHP assigning `$wp_cache_* = …;` lines; we extract them with a
1237 * regex rather than including the file (including it would define
1238 * constants / run code in our request).
1239 *
1240 * @return array|null name => value for the recognised globals, or null.
1241 */
1242 private static function read_wpsc_config_file(): ?array {
1243 if ( ! defined( 'WP_CONTENT_DIR' ) ) {
1244 return null;
1245 }
1246 $path = WP_CONTENT_DIR . '/wp-cache-config.php';
1247 if ( ! is_readable( $path ) ) {
1248 return null;
1249 }
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.
1251 if ( false === $raw || '' === $raw ) {
1252 return null;
1253 }
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' );
1263 foreach ( $keys as $key ) {
1264 // Match `$key = 1;` / `$key = '1';` / `$key = true;` etc.
1265 if ( preg_match( '/\$' . preg_quote( $key, '/' ) . '\s*=\s*([^;]+);/', $raw, $m ) ) {
1266 $val = trim( $m[1], " \t'\"" );
1267 $out[ $key ] = in_array( strtolower( $val ), array( '1', 'true' ), true );
1268 }
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
1280 return ! empty( $out ) ? $out : null;
1281 }
1282
1283 /** Thin wrapper so detection works before admin plugin.php is loaded. */
1284 private static function plugin_active( string $plugin ): bool {
1285 $active = (array) get_option( 'active_plugins', array() );
1286 if ( in_array( $plugin, $active, true ) ) {
1287 return true;
1288 }
1289 // Network-activated (multisite).
1290 $network = (array) get_site_option( 'active_sitewide_plugins', array() );
1291 return isset( $network[ $plugin ] );
1292 }
1293
1294 public static function plan_wpsc( array $r ): array {
1295 $plan = array(
1296 'cache' => array(
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'] ),
1301 ),
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;
1307 }
1308
1309 // ─────────────────────────── LiteSpeed Cache ─────────────────────
1310
1311 /**
1312 * Read LiteSpeed Cache settings into a flat `name => value` array keyed
1313 * by LiteSpeed's dotted setting names (cache, cache-mobile, optm-*,
1314 * media-*, object-*, cdn-*, …) — the shape plan_litespeed() expects.
1315 *
1316 * Storage has changed across LiteSpeed versions:
1317 * - v4+ (current): ONE option PER setting, named `litespeed.conf.<name>`
1318 * (e.g. litespeed.conf.cache, litespeed.conf.cache-mobile). There is
1319 * NO single `litespeed.conf` blob — reading that key returns null,
1320 * which is why detection used to fail on every modern install.
1321 * - v3 and earlier: a single serialized array under `litespeed.conf`
1322 * (or the legacy `litespeed-cache-conf`).
1323 * We handle all three: try the per-option family first (the common case
1324 * today), then fall back to the legacy single-blob options.
1325 *
1326 * @return array|null raw conf (name => value), or null when absent.
1327 */
1328 public static function detect_litespeed(): ?array {
1329 global $wpdb;
1330
1331 // v4+: individual `litespeed.conf.<name>` options. Pull them all and
1332 // strip the prefix so keys match what plan_litespeed() reads.
1333 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- one-time settings-import scan of another plugin's option rows by name prefix; no WP API bulk-reads by option_name LIKE, and caching a single migration-time read is pointless.
1334 $rows = $wpdb->get_results(
1335 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'litespeed.conf.%'",
1336 ARRAY_A
1337 );
1338 if ( ! empty( $rows ) ) {
1339 $conf = array();
1340 foreach ( $rows as $row ) {
1341 $name = substr( (string) $row['option_name'], strlen( 'litespeed.conf.' ) );
1342 if ( '' === $name || '_version' === $name ) {
1343 continue;
1344 }
1345 $conf[ $name ] = self::decode_litespeed_value( (string) $row['option_value'] );
1346 }
1347 if ( ! empty( $conf ) ) {
1348 return $conf;
1349 }
1350 }
1351
1352 // v3 / legacy: a single serialized array.
1353 $opt = get_option( 'litespeed.conf', null );
1354 if ( ! is_array( $opt ) ) {
1355 $opt = get_option( 'litespeed-cache-conf', null );
1356 }
1357 return is_array( $opt ) && ! empty( $opt ) ? $opt : null;
1358 }
1359
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 /**
1387 * Translate LiteSpeed's `litespeed.conf` into xSpeed module patches.
1388 * LiteSpeed uses dotted keys; values are mostly bool-ish (1/0/'1').
1389 * We map only the settings that have a clean xSpeed equivalent and
1390 * leave the rest untouched so nothing is silently mis-imported.
1391 *
1392 * @param array $r raw litespeed.conf.
1393 */
1394 public static function plan_litespeed( array $r ): array {
1395 $on = static function ( $key ) use ( $r ): bool {
1396 return isset( $r[ $key ] ) && ! empty( $r[ $key ] );
1397 };
1398 // LiteSpeed list fields are stored as either a newline-delimited
1399 // string or an array. Normalize to a clean string[] either way.
1400 $list = static function ( $key ) use ( $r ): array {
1401 $v = $r[ $key ] ?? null;
1402 if ( is_string( $v ) ) {
1403 $v = preg_split( '/\r\n|\r|\n/', $v );
1404 }
1405 if ( ! is_array( $v ) ) {
1406 return array();
1407 }
1408 return array_values( array_filter( array_map( 'trim', array_map( 'strval', $v ) ) ) );
1409 };
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 }
1427 if ( $vals ) {
1428 $dest[ $dest_key ] = $vals;
1429 }
1430 };
1431
1432 $patch = array();
1433
1434 // ── Page cache ────────────────────────────────────────────────
1435 $patch['cache'] = array(
1436 'enabled' => $on( 'cache' ) || $on( 'cache-priv' ),
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' ) );
1441 // TTL: LiteSpeed stores cache-ttl_pub in seconds → xSpeed wants hours.
1442 if ( isset( $r['cache-ttl_pub'] ) && (int) $r['cache-ttl_pub'] > 0 ) {
1443 $patch['cache']['cache_expiry'] = max( 1, min( 720, (int) ( (int) $r['cache-ttl_pub'] / 3600 ) ) );
1444 }
1445 // Excluded URIs / cookies / user-agents / dropped query strings.
1446 $set_list( $patch['cache'], 'excluded_urls', $list( 'cache-exc' ) );
1447 $set_list( $patch['cache'], 'excluded_cookies', $list( 'cache-exc_cookies' ) );
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' ) );
1452 // LiteSpeed "drop query string" list ≈ xSpeed ignored_query_params.
1453 $set_list( $patch['cache'], 'ignored_query_params', $list( 'cache-drop_qs' ) );
1454
1455 // ── Minify / optimization ─────────────────────────────────────
1456 $patch['minify'] = array(
1457 'minify_html' => $on( 'optm-html_min' ),
1458 'minify_css' => $on( 'optm-css_min' ),
1459 'minify_js' => $on( 'optm-js_min' ),
1460 'combine_css' => $on( 'optm-css_comb' ),
1461 'combine_js' => $on( 'optm-js_comb' ),
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'],
1470 // Async/“load CSS asynchronously” — LiteSpeed CCSS async.
1471 'async_css' => $on( 'optm-css_async' ),
1472 // Remove query strings from static resources.
1473 'remove_query_strings' => $on( 'optm-qs_rm' ),
1474 );
1475 // Defer/delay exclusion list — merge LiteSpeed's JS defer + delay
1476 // exclude lists into xSpeed's single defer_js_excluded.
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' );
1483 $set_list( $patch['minify'], 'defer_js_excluded', $defer_exc );
1484 $set_list( $patch['minify'], 'delay_js_targets', $list( 'optm-js_delay_inc' ) );
1485
1486 // ── Lazy load (media) ─────────────────────────────────────────
1487 $patch['lazy'] = array(
1488 'lazy_images' => $on( 'media-lazy' ),
1489 'lazy_iframes' => $on( 'media-iframe_lazy' ),
1490 // LiteSpeed has no separate HTML5-video lazy toggle; mirror the
1491 // image setting so video preload follows the same intent.
1492 'lazy_videos' => $on( 'media-lazy' ),
1493 // "Add Missing Sizes" → add_missing_dimensions (anti-CLS).
1494 'add_missing_dimensions' => $on( 'media-add_missing_sizes' ),
1495 );
1496 $set_list( $patch['lazy'], 'excluded_images', $list( 'media-lazy_exc' ) );
1497
1498 // ── Fonts ─────────────────────────────────────────────────────
1499 // LiteSpeed "Font Display Optimization" (optm-localize_style /
1500 // optm-css_font_display) → xSpeed font-display: swap.
1501 if ( $on( 'optm-css_font_display' ) || $on( 'optm-localize' ) ) {
1502 $patch['fonts'] = array( 'font_display_swap' => true );
1503 }
1504
1505 // ── Disable bloat ─────────────────────────────────────────────
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.
1510 if ( $on( 'optm-emoji_rm' ) ) {
1511 $patch['bloat'] = array( 'disable_emojis' => true );
1512 }
1513
1514 // ── Browser cache (LiteSpeed: cache-browser) ──────────────────
1515 if ( $on( 'cache-browser' ) ) {
1516 $patch['browser-cache'] = array( 'enabled' => true );
1517 if ( isset( $r['cache-ttl_browser'] ) && (int) $r['cache-ttl_browser'] > 0 ) {
1518 $patch['browser-cache']['asset_ttl'] = (int) $r['cache-ttl_browser'];
1519 }
1520 }
1521
1522 // ── Object cache ──────────────────────────────────────────────
1523 if ( $on( 'object' ) ) {
1524 $kind = isset( $r['object-kind'] ) && (int) $r['object-kind'] === 1 ? 'redis' : 'memcached';
1525 $patch['object-cache'] = array( 'backend' => $kind );
1526 if ( ! empty( $r['object-host'] ) ) {
1527 $host_key = 'redis' === $kind ? 'redis_host' : 'memcached_host';
1528 $patch['object-cache'][ $host_key ] = (string) $r['object-host'];
1529 }
1530 if ( ! empty( $r['object-port'] ) ) {
1531 $port_key = 'redis' === $kind ? 'redis_port' : 'memcached_port';
1532 $patch['object-cache'][ $port_key ] = (int) $r['object-port'];
1533 }
1534 if ( 'redis' === $kind && isset( $r['object-db_id'] ) ) {
1535 $patch['object-cache']['redis_database'] = max( 0, min( 15, (int) $r['object-db_id'] ) );
1536 }
1537 if ( ! empty( $r['object-pswd'] ) && 'redis' === $kind ) {
1538 $patch['object-cache']['redis_password'] = (string) $r['object-pswd'];
1539 }
1540 if ( ! empty( $r['object-global_groups'] ) || ! empty( $r['object-persistent'] ) ) {
1541 $patch['object-cache']['persistent'] = $on( 'object-persistent' );
1542 }
1543 }
1544
1545 // ── Image conversion (Pro Images module) ──────────────────────
1546 // LiteSpeed media-webp / next-gen image generation → xSpeed Images.
1547 if ( $on( 'img_optm-webp' ) || $on( 'media-webp' ) || $on( 'img_optm-auto' ) ) {
1548 $patch['images'] = array(
1549 'webp' => $on( 'img_optm-webp' ) || $on( 'media-webp' ),
1550 'avif' => $on( 'img_optm-avif' ),
1551 );
1552 }
1553
1554 // ── CDN ───────────────────────────────────────────────────────
1555 if ( $on( 'cdn' ) ) {
1556 $cdn_url = '';
1557 if ( ! empty( $r['cdn-mapping'] ) && is_array( $r['cdn-mapping'] ) ) {
1558 $first = $r['cdn-mapping'][0] ?? array();
1559 // LiteSpeed cdn-mapping rows use the 'url' sub-key (array form)
1560 // or a bare URL string (legacy).
1561 $cdn_url = is_array( $first ) ? (string) ( $first['url'] ?? ( $first['cdn_url'] ?? '' ) ) : (string) $first;
1562 }
1563 if ( '' !== $cdn_url ) {
1564 $patch['cdn'] = array(
1565 'enabled' => true,
1566 'cdn_url' => $cdn_url,
1567 );
1568 // LiteSpeed's O_CDN_EXC is `cdn-exc`, not `cdn-exclude`. (#217)
1569 $set_list( $patch['cdn'], 'excluded_patterns', $list( 'cdn-exc' ) );
1570 }
1571 }
1572
1573 return $patch;
1574 }
1575 }
1576