PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.0 1.2.1 1.2.2 1.2.3
xspeed / includes / class-migration.php

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

1,533 lines 62.8 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 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 /**
408 * Map a source plugin's "separate mobile cache" flag onto the cache patch
409 * WITHOUT enabling xSpeed's mobile_separate. The xSpeed static-file fast
410 * path is device-blind, so mobile_separate=ON disables it — and a source
411 * site that had the flag on very often serves identical HTML to every
412 * device (it was on by habit). Rather than silently kill the fast path on
413 * import, we keep mobile_separate off and, when the source had it on, set
414 * `mobile_separate_review` so the dashboard can prompt the user to turn it
415 * back on only if their site really differs per device.
416 * (FBS-83144 / FBS-83145)
417 *
418 * @param array $patch The plan patch (modified by reference).
419 * @param bool $source_on Whether the source plugin had mobile-separate on.
420 */
421 private static function map_mobile_separate( array &$patch, bool $source_on ): void {
422 if ( ! isset( $patch['cache'] ) || ! is_array( $patch['cache'] ) ) {
423 $patch['cache'] = array();
424 }
425 // Never import as ON; leave the fast path intact.
426 $patch['cache']['mobile_separate'] = false;
427 if ( $source_on ) {
428 $patch['cache']['mobile_separate_review'] = true;
429 }
430 }
431
432 /**
433 * Return the patch that `apply()` would write, without writing.
434 */
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 {
455 $src = self::sources()[ $source_id ] ?? null;
456 if ( null === $src ) {
457 return null;
458 }
459 $raw = call_user_func( $src['detect'] );
460 if ( ! is_array( $raw ) ) {
461 return null;
462 }
463
464 return array(
465 'patch' => call_user_func( $src['plan'], $raw ),
466 'notes' => self::preview_notes( $source_id, $raw ),
467 );
468 }
469
470 /**
471 * Read source settings + write the translated patch. Returns the
472 * per-module write results, same shape as Recommendations::apply.
473 */
474 public static function apply( string $source_id ): array {
475 $patch = self::preview( $source_id );
476 if ( null === $patch ) {
477 return array();
478 }
479 $results = array();
480 foreach ( $patch as $slug => $values ) {
481 if ( ! is_string( $slug ) || ! is_array( $values ) ) {
482 continue;
483 }
484 // Import only the settings that are actually enabled / non-empty in
485 // the source. A plan patch emits every mapped field including the
486 // ones the source turned OFF; merging those `false`/empty values
487 // would silently DISABLE settings the user already had on in xSpeed.
488 // Migration is additive — it never clobbers existing config with a
489 // source's disabled value. (FBS-82449)
490 $meaningful = self::meaningful_values( $values );
491 if ( empty( $meaningful ) ) {
492 continue;
493 }
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
606 $results[ $slug ] = array(
607 'ok' => $ok,
608 'applied' => $applied,
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 }
626 }
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 ) ) {
633 self::mark_imported( $source_id );
634 }
635 return $results;
636 }
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
684 /**
685 * Is it safe to switch the source plugin off?
686 *
687 * Stricter than "did anything import" and looser than "did everything":
688 * every module that is not an optional extra must have applied cleanly,
689 * and at least one must have applied at all. A failure in an optional
690 * module is reported to the user either way — it just does not veto the
691 * handover the notice already promised.
692 *
693 * @param array<string,array<string,mixed>> $results apply() output.
694 */
695 public static function safe_to_hand_over( array $results ): bool {
696 if ( empty( $results ) ) {
697 return false;
698 }
699
700 $applied = false;
701 foreach ( $results as $slug => $result ) {
702 if ( ! is_array( $result ) ) {
703 return false;
704 }
705 if ( empty( $result['ok'] ) ) {
706 if ( ! in_array( (string) $slug, self::OPTIONAL_FOR_HANDOVER, true ) ) {
707 return false;
708 }
709 continue;
710 }
711 if ( ! empty( $result['applied'] ) ) {
712 $applied = true;
713 }
714 }
715
716 return $applied;
717 }
718
719 /**
720 * Install the object-cache drop-in for a just-imported backend, and fold
721 * the outcome into that module's result row.
722 *
723 * A failure here is reported, never silent: the import has already told
724 * the user their object cache came across.
725 *
726 * @param array $result The module's result row so far.
727 * @return array The row, with ok/message reflecting the drop-in install.
728 */
729 private static function enable_object_cache( array $result ): array {
730 if ( ! class_exists( __NAMESPACE__ . '\\Object_Cache' ) ) {
731 return $result;
732 }
733
734 $opts = (array) Settings_Manager::get( 'object-cache' );
735 $state = Object_Cache::enable( $opts );
736
737 $result['ok'] = ! empty( $state['ok'] );
738 $result['object_cache_ready'] = ! empty( $state['ok'] );
739 if ( empty( $state['ok'] ) ) {
740 // Surfaced by the panel instead of a green "imported" message.
741 $result['message'] = (string) ( $state['message'] ?? 'Could not enable the object cache.' );
742 }
743
744 return $result;
745 }
746
747 // ─────────────────────────── WP Rocket ───────────────────────────
748
749 public static function detect_wp_rocket(): ?array {
750 $opt = get_option( 'wp_rocket_settings', null );
751 return is_array( $opt ) ? $opt : null;
752 }
753
754 /**
755 * Translate WP Rocket's `wp_rocket_settings` array into our module
756 * settings. Only safe-to-port booleans + counts; behaviorally
757 * different toggles (Critical CSS, RUCSS) skip — Pro handles those.
758 *
759 * @param array $r raw wp_rocket_settings.
760 */
761 public static function plan_wp_rocket( array $r ): array {
762 $patch = array();
763 // Page caching.
764 // WP Rocket has no master on/off switch — installing and activating it
765 // IS enabling page caching, so a detected settings blob means the
766 // source site was caching. `cache_logged_user` is NOT that switch: it
767 // controls whether LOGGED-IN users get cached pages. Reading it as the
768 // master meant the most ordinary WP Rocket configuration of all —
769 // caching on, but not for logged-in users (`cache_logged_user = 0`) —
770 // imported as caching OFF, the exact inverse of the user's intent. The
771 // `! isset` fallback then made a missing key mean ON, so the result was
772 // right only by accident. (#222 F2)
773 $patch['cache'] = array(
774 'enabled' => true,
775 // The Cache module's TTL setting is `cache_expiry` (hours), NOT
776 // `expiry_hours` — the latter is a dead key nothing reads, so the
777 // imported lifetime was silently dropped. Clamp to the same 1–720h
778 // range the Cache schema + LiteSpeed importer use. (FBS-83144)
779 'cache_expiry' => isset( $r['purge_cron_interval'] ) ? max( 1, min( 720, (int) ( (int) $r['purge_cron_interval'] / 3600 ) ) ) : 24,
780 );
781 // Excluded URLs / cookies — both are arrays of strings in WP Rocket.
782 if ( ! empty( $r['cache_reject_uri'] ) && is_array( $r['cache_reject_uri'] ) ) {
783 $patch['cache']['excluded_urls'] = array_values( array_filter( array_map( 'strval', $r['cache_reject_uri'] ) ) );
784 }
785 if ( ! empty( $r['cache_reject_cookies'] ) && is_array( $r['cache_reject_cookies'] ) ) {
786 $patch['cache']['excluded_cookies'] = array_values( array_filter( array_map( 'strval', $r['cache_reject_cookies'] ) ) );
787 }
788
789 // Minify.
790 $patch['minify'] = array(
791 'minify_html' => ! empty( $r['minify_html'] ),
792 'minify_css' => ! empty( $r['minify_css'] ),
793 'minify_js' => ! empty( $r['minify_js'] ),
794 'combine_css' => ! empty( $r['minify_concatenate_css'] ),
795 'combine_js' => ! empty( $r['minify_concatenate_js'] ),
796 'defer_js' => ! empty( $r['defer_all_js'] ),
797 );
798
799 // Lazy load.
800 $patch['lazy'] = array(
801 'lazy_images' => ! empty( $r['lazyload'] ),
802 'lazy_iframes' => ! empty( $r['lazyload_iframes'] ),
803 'lazy_videos' => ! empty( $r['lazyload_youtube'] ),
804 );
805
806 // Separate Mobile Cache — do NOT import this as ON. WP Rocket's
807 // "separate cache files for mobile" is frequently left on by habit even
808 // when the site serves identical HTML to every device, and xSpeed's
809 // static-file fast path is device-blind — enabling mobile_separate
810 // DISABLES it, silently dropping the site from HIT (nginx) to HIT (php).
811 // Instead, keep the fast path (mobile_separate stays false) and flag it
812 // for review so the dashboard can prompt the user to re-enable it only
813 // if their site really differs per device. (FBS-83144 / FBS-83145)
814 self::map_mobile_separate( $patch, ! empty( $r['do_caching_mobile_files'] ) );
815
816 // Preloader.
817 if ( ! empty( $r['manual_preload'] ) || ! empty( $r['sitemap_preload'] ) ) {
818 $patch['preloader'] = array(
819 'enabled' => true,
820 'schedule' => 'daily',
821 );
822 if ( ! empty( $r['sitemap_preload_url'] ) && is_array( $r['sitemap_preload_url'] ) ) {
823 $patch['preloader']['sitemap_urls'] = array_values( array_filter( array_map( 'strval', $r['sitemap_preload_url'] ) ) );
824 }
825 }
826
827 // CDN — WP Rocket stores CDN hosts in cdn_cnames (array).
828 if ( ! empty( $r['cdn'] ) && ! empty( $r['cdn_cnames'] ) && is_array( $r['cdn_cnames'] ) ) {
829 $first = (string) ( $r['cdn_cnames'][0] ?? '' );
830 if ( '' !== $first ) {
831 $patch['cdn'] = array(
832 'enabled' => true,
833 'cdn_url' => $first,
834 );
835 }
836 }
837
838 return $patch;
839 }
840
841 // ─────────────────────────── W3 Total Cache ──────────────────────
842
843 public static function detect_w3tc(): ?array {
844 // W3 Total Cache does NOT store its config in the options table — it
845 // writes a PHP file at wp-content/w3tc-config/master.php whose body
846 // is a short PHP guard followed by a JSON blob of dotted-key settings
847 // (pgcache.enabled, minify.html.enable, …). Reading w3tc_config /
848 // w3tc_master_settings options always returned null, so detection
849 // failed on every install. Read + parse the config file instead.
850 $cfg = self::read_w3tc_config_file();
851 if ( is_array( $cfg ) && ! empty( $cfg ) ) {
852 return $cfg;
853 }
854 // Defensive fallback for any build that did persist an options blob.
855 $opt = get_option( 'w3tc_config', null );
856 if ( ! is_array( $opt ) ) {
857 $opt = get_option( 'w3tc_master_settings', null );
858 }
859 return is_array( $opt ) ? $opt : null;
860 }
861
862 /**
863 * Parse W3TC's master config file into a flat dotted-key array.
864 * Format: a short PHP guard (a php-open, exit, php-close) immediately
865 * followed by a JSON object. We strip everything up to and including the
866 * PHP closing tag, then JSON-decode the remainder.
867 *
868 * @return array|null parsed config, or null if the file is missing/unreadable.
869 */
870 private static function read_w3tc_config_file(): ?array {
871 if ( ! defined( 'WP_CONTENT_DIR' ) ) {
872 return null;
873 }
874 $path = WP_CONTENT_DIR . '/w3tc-config/master.php';
875 if ( ! is_readable( $path ) ) {
876 return null;
877 }
878 $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.
879 if ( false === $raw || '' === $raw ) {
880 return null;
881 }
882 // Drop the leading PHP guard and decode the JSON tail. The pattern
883 // matches up to the first PHP closing tag; built from a char-code so
884 // no literal close tag appears in this source file.
885 $close_tag = '?' . '>';
886 $json = preg_replace( '/^.*?' . preg_quote( $close_tag, '/' ) . '/s', '', $raw );
887 $cfg = json_decode( trim( (string) $json ), true );
888 return is_array( $cfg ) ? $cfg : null;
889 }
890
891 public static function plan_w3tc( array $r ): array {
892 $patch = array();
893 $patch['cache'] = array(
894 'enabled' => ! empty( $r['pgcache.enabled'] ),
895 // Cache module reads `cache_expiry` (hours), not the dead
896 // `expiry_hours` key — see plan_wp_rocket. (FBS-83144)
897 // W3TC stores this in SECONDS and sub-hour values are common (900 /
898 // 1800 are its own defaults). Integer division floored those to 0
899 // and max(1, …) then bumped them to a full hour, so a 5-minute
900 // lifetime silently became 12x longer. xSpeed's cache_expiry is
901 // hour-granular, so the closest honest answer is to round to
902 // nearest and keep the 1-hour floor for anything under 30 minutes.
903 // preview_notes() tells the user when the source value could not be
904 // represented exactly. (#218)
905 'cache_expiry' => isset( $r['pgcache.lifetime'] ) ? self::seconds_to_hours( (int) $r['pgcache.lifetime'] ) : 24,
906 );
907 $w3_list = static function ( $key ) use ( $r ): array {
908 $v = $r[ $key ] ?? null;
909 return is_array( $v ) ? array_values( array_filter( array_map( 'strval', $v ) ) ) : array();
910 };
911
912 foreach ( array(
913 'pgcache.reject.uri' => 'excluded_urls',
914 'pgcache.reject.cookie' => 'excluded_cookies',
915 'pgcache.reject.ua' => 'bypass_user_agents',
916 ) as $src => $dest ) {
917 $vals = $w3_list( $src );
918 if ( $vals ) {
919 $patch['cache'][ $dest ] = $vals;
920 }
921 }
922
923 // `pgcache.accept.qs` is deliberately NOT imported. W3TC ships ~100
924 // tracking parameters in it by default, so importing it wholesale
925 // would bury the user's own additions under a stock list — and
926 // apply() unions lists, which would make that permanent. Our own
927 // ignored_query_params default already covers the same ground.
928 // (#218)
929
930 self::map_mobile_separate( $patch, ! empty( $r['mobile.enabled'] ) );
931
932 // W3TC's minify "method" encodes BOTH operations in one value:
933 // 'minify' | 'combine' | 'both'. Combining is on for the latter two.
934 $method_combines = static function ( $key ) use ( $r ): bool {
935 $m = isset( $r[ $key ] ) ? (string) $r[ $key ] : '';
936 return 'combine' === $m || 'both' === $m;
937 };
938
939 // The master switch gates every child mapping.
940 //
941 // W3TC keeps its child defaults POPULATED while `minify.enabled` is
942 // off — `minify.css.enable`, `minify.js.enable` and the method fields
943 // all read as truthy on a site that has minification deliberately
944 // switched off. Reading the children alone therefore imported Minify
945 // CSS/JS and Combine CSS/JS as ON for a user who had turned the whole
946 // feature off, which can change front-end output and introduce the
947 // exact CSS/JS regressions migration is supposed to avoid.
948 //
949 // Every other W3TC block here already gates this way — pgcache on
950 // `pgcache.enabled`, lazy load on `lazyload.enabled`, browser cache on
951 // `browsercache.enabled`, object cache on `objectcache.enabled`.
952 // Minify was the one that did not. (#218)
953 $minify_on = ! empty( $r['minify.enabled'] );
954
955 $patch['minify'] = array(
956 'minify_html' => $minify_on && ! empty( $r['minify.html.enable'] ),
957 'minify_css' => $minify_on && ! empty( $r['minify.css.enable'] ),
958 'minify_js' => $minify_on && ! empty( $r['minify.js.enable'] ),
959 // There is no `minify.css.combine`; CSS combining lives in the
960 // method. JS splits its combine flag across three placements, and
961 // any one of them means the user wanted combining.
962 'combine_css' => $minify_on && $method_combines( 'minify.css.method' ),
963 'combine_js' => $minify_on && (
964 $method_combines( 'minify.js.method' )
965 || ! empty( $r['minify.js.combine.header'] )
966 || ! empty( $r['minify.js.combine.body'] )
967 || ! empty( $r['minify.js.combine.footer'] )
968 ),
969 );
970
971 // ── Lazy load ────────────────────────────────────────────────────
972 if ( ! empty( $r['lazyload.enabled'] ) ) {
973 $patch['lazy'] = array( 'lazy_images' => true );
974 $excluded = $w3_list( 'lazyload.exclude' );
975 if ( $excluded ) {
976 $patch['lazy']['excluded_images'] = $excluded;
977 }
978 }
979
980 // ── Browser cache + compression ──────────────────────────────────
981 if ( ! empty( $r['browsercache.enabled'] ) ) {
982 $patch['browser-cache'] = array( 'enabled' => true );
983 foreach ( array(
984 'browsercache.cssjs.lifetime' => 'asset_ttl',
985 'browsercache.html.lifetime' => 'html_ttl',
986 ) as $src => $dest ) {
987 if ( ! empty( $r[ $src ] ) ) {
988 $patch['browser-cache'][ $dest ] = (int) $r[ $src ];
989 }
990 }
991 // W3TC has a compression toggle per content type; xSpeed has one
992 // switch, so any of them being on means the user wanted GZIP.
993 if ( ! empty( $r['browsercache.html.compression'] ) || ! empty( $r['browsercache.cssjs.compression'] ) || ! empty( $r['browsercache.other.compression'] ) ) {
994 $patch['gzip'] = array( 'gzip_enabled' => true );
995 }
996 }
997
998 // ── Preloader (W3TC calls it "cache priming") ────────────────────
999 if ( ! empty( $r['pgcache.prime.enabled'] ) ) {
1000 $patch['preloader'] = array( 'enabled' => true );
1001 if ( ! empty( $r['pgcache.prime.sitemap'] ) ) {
1002 $patch['preloader']['sitemap_url'] = (string) $r['pgcache.prime.sitemap'];
1003 }
1004 if ( ! empty( $r['pgcache.prime.interval'] ) ) {
1005 $patch['preloader']['schedule'] = self::seconds_to_schedule( (int) $r['pgcache.prime.interval'] );
1006 }
1007 if ( ! empty( $r['pgcache.prime.post.update.enabled'] ) ) {
1008 $patch['preloader']['warm_on_publish'] = true;
1009 }
1010 }
1011
1012 // ── Bloat ────────────────────────────────────────────────────────
1013 if ( ! empty( $r['jquerymigrate.disabled'] ) ) {
1014 $patch['bloat'] = array( 'strip_jquery_migrate' => true );
1015 }
1016
1017 if ( ! empty( $r['objectcache.enabled'] ) && ! empty( $r['objectcache.engine'] ) ) {
1018 $is_memcached = 'memcached' === $r['objectcache.engine'];
1019 $engine = $is_memcached ? 'memcached' : 'redis';
1020
1021 $patch['object-cache'] = array( 'backend' => $engine );
1022
1023 // W3TC namespaces these by engine — `objectcache.redis.servers` /
1024 // `objectcache.memcached.servers`. There is no bare
1025 // `objectcache.servers`, so the host/port branch here could never
1026 // run for a W3TC source: a site with Redis on a non-default host or
1027 // port silently fell back to 127.0.0.1:6379. (#218)
1028 $servers = $r[ 'objectcache.' . $engine . '.servers' ] ?? null;
1029 if ( ! empty( $servers ) && is_array( $servers ) ) {
1030 $first = (string) ( $servers[0] ?? '' );
1031
1032 // A scheme says HOW to connect, not WHERE. Left in place it
1033 // becomes the hostname: Redis_Client::connect() builds
1034 // "tcp://{$host}:{$port}", so `tls://redis.example` produces
1035 // `tcp://tls://redis.example:6380` and resolution fails on the
1036 // literal host "tls". The same string is also written into the
1037 // drop-in's WP_REDIS_HOST, so the imported object cache could
1038 // never connect. A managed Redis on TLS is the common case.
1039 // Bracketed IPv6 is left alone — stream_socket_client() wants
1040 // the brackets. (#224)
1041 $first = (string) preg_replace( '#^[a-z][a-z0-9+.-]*://#i', '', $first );
1042 $separator = strrpos( $first, ':' );
1043 if ( false !== $separator ) {
1044 $host = substr( $first, 0, $separator );
1045 $port = substr( $first, $separator + 1 );
1046 $patch['object-cache'][ $engine . '_host' ] = $host;
1047 $patch['object-cache'][ $engine . '_port' ] = (int) $port;
1048 }
1049 }
1050
1051 // Everything else that has a destination in ObjectCacheModule and
1052 // was previously dropped. A non-zero Redis DB index matters most:
1053 // the connection would succeed while pointing at the wrong dataset.
1054 $copy = $is_memcached
1055 ? array( 'objectcache.memcached.persistent' => 'persistent' )
1056 : array(
1057 'objectcache.redis.dbid' => 'redis_database',
1058 'objectcache.redis.password' => 'redis_password',
1059 'objectcache.redis.persistent' => 'persistent',
1060 'objectcache.redis.timeout' => 'connection_timeout',
1061 );
1062 $numeric_dests = array( 'redis_database', 'connection_timeout' );
1063 foreach ( $copy as $src => $dest ) {
1064 if ( ! isset( $r[ $src ] ) || '' === $r[ $src ] ) {
1065 continue;
1066 }
1067 // W3TC stores an unset timeout / db index as 0, which carries no
1068 // intent — emitting it would put a meaningless value in the plan
1069 // preview the user is asked to confirm. Only the numeric fields
1070 // get that treatment: casting a password to int would read
1071 // "s3cret" as 0 and silently drop it.
1072 $is_numeric_dest = in_array( $dest, $numeric_dests, true );
1073 if ( $is_numeric_dest && 0 === (int) $r[ $src ] ) {
1074 continue;
1075 }
1076 // W3 Total Cache 2.8+ encrypts secrets in its config file
1077 // (Util_Crypto, `enc:v1:` prefix). Copying the ciphertext
1078 // through would hand Redis a password that can never
1079 // authenticate — and it is exactly the password-protected
1080 // sources this mapping exists to serve. Decrypt with W3TC's
1081 // own helper; if that is unavailable (no crypto key, plugin
1082 // files already gone), SKIP the field rather than import an
1083 // unusable value: a missing password is something the user
1084 // can fix in one edit, a silently wrong one is not. (#224 F1)
1085 if ( 'redis_password' === $dest ) {
1086 $secret = self::decrypt_w3tc_secret( (string) $r[ $src ] );
1087 if ( null === $secret ) {
1088 continue;
1089 }
1090 $patch['object-cache'][ $dest ] = $secret;
1091 continue;
1092 }
1093 $patch['object-cache'][ $dest ] = $is_numeric_dest
1094 ? (int) $r[ $src ]
1095 : $r[ $src ];
1096 }
1097 // W3TC's Cache_Redis only ever calls auth( $password ) — it has no
1098 // ACL username support — so a W3TC source never carries one and we
1099 // must not invent a redis_user here.
1100 }
1101
1102 return $patch;
1103 }
1104
1105 /**
1106 * Resolve a W3 Total Cache secret to plaintext.
1107 *
1108 * W3TC 2.8+ stores secrets encrypted with its own `Util_Crypto`, marked
1109 * by an `enc:v1:` prefix. A plaintext value (older W3TC, or a config
1110 * written before encryption landed) is returned unchanged.
1111 *
1112 * Returns null when the value is encrypted but cannot be decrypted —
1113 * W3TC's classes are not loadable, or its crypto key is gone. Callers
1114 * MUST treat null as "skip this field", never as an empty password:
1115 * importing the ciphertext guarantees an auth failure, and importing an
1116 * empty string would silently drop a password the source really had.
1117 *
1118 * @param string $value Raw value from the W3TC config.
1119 * @return string|null Plaintext, or null when it cannot be resolved.
1120 */
1121 private static function decrypt_w3tc_secret( string $value ): ?string {
1122 if ( 0 !== strpos( $value, 'enc:' ) ) {
1123 return $value;
1124 }
1125
1126 if ( ! class_exists( '\\W3TC\\Util_Crypto' ) ) {
1127 return null;
1128 }
1129
1130 // W3TC's method is envelope_decrypt(), NOT decrypt(). Guarding on the
1131 // wrong name meant method_exists() was false on every install, the
1132 // helper returned null before it ever ran, and the password was
1133 // silently dropped from every import — the exact users the decrypt
1134 // support was written for. Verified against W3TC 2.10.5:
1135 //
1136 // ::decrypt() MISSING
1137 // ::envelope_decrypt() EXISTS
1138 // ::is_envelope() EXISTS
1139 //
1140 // Kept as a list so an older/newer W3TC that renames it again
1141 // degrades to "skip the field" rather than to a fatal. (#218 F1)
1142 $method = null;
1143 foreach ( array( 'envelope_decrypt', 'decrypt' ) as $candidate ) {
1144 if ( method_exists( '\\W3TC\\Util_Crypto', $candidate ) ) {
1145 $method = $candidate;
1146 break;
1147 }
1148 }
1149 if ( null === $method ) {
1150 return null;
1151 }
1152
1153 try {
1154 $plain = \W3TC\Util_Crypto::$method( $value );
1155 } catch ( \Throwable $e ) {
1156 return null;
1157 }
1158
1159 // A failed decrypt can come back as false/null/'' or as the
1160 // untouched ciphertext depending on the failure mode. None of those
1161 // are a usable password.
1162 if ( ! is_string( $plain ) || '' === $plain || 0 === strpos( $plain, 'enc:' ) ) {
1163 return null;
1164 }
1165
1166 return $plain;
1167 }
1168
1169 // ─────────────────────────── WP Super Cache ──────────────────────
1170
1171 public static function detect_wpsc(): ?array {
1172 // WP Super Cache stores its settings as PHP globals in
1173 // wp-content/wp-cache-config.php (NOT the options table — the old
1174 // get_option('wp_cache_enabled') reads always returned null). Parse
1175 // the config file for the globals plan_wpsc() needs. If the file
1176 // doesn't exist yet (plugin active but never configured), fall back
1177 // to a minimal "active" marker so the source still appears in the UI
1178 // and a default import is possible.
1179 $cfg = self::read_wpsc_config_file();
1180 if ( is_array( $cfg ) && ! empty( $cfg ) ) {
1181 return $cfg;
1182 }
1183 if ( self::plugin_active( 'wp-super-cache/wp-cache.php' ) ) {
1184 // Active but unconfigured — expose the on/off intent only.
1185 return array( 'cache_enabled' => defined( 'WPCACHEHOME' ) );
1186 }
1187 return null;
1188 }
1189
1190 /**
1191 * Parse the WP Super Cache config file for the globals we map. The file
1192 * is plain PHP assigning `$wp_cache_* = …;` lines; we extract them with a
1193 * regex rather than including the file (including it would define
1194 * constants / run code in our request).
1195 *
1196 * @return array|null name => value for the recognised globals, or null.
1197 */
1198 private static function read_wpsc_config_file(): ?array {
1199 if ( ! defined( 'WP_CONTENT_DIR' ) ) {
1200 return null;
1201 }
1202 $path = WP_CONTENT_DIR . '/wp-cache-config.php';
1203 if ( ! is_readable( $path ) ) {
1204 return null;
1205 }
1206 $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.
1207 if ( false === $raw || '' === $raw ) {
1208 return null;
1209 }
1210 $out = array();
1211
1212 // `$cache_enabled` is the master switch and `$super_cache_enabled`
1213 // selects mod_rewrite mode. WP Super Cache does NOT define
1214 // `$wp_cache_enabled` — reading that name meant the on/off intent was
1215 // never populated, plan_wpsc() computed `enabled => false`, and
1216 // meaningful_values() then dropped the false boolean entirely. A site
1217 // actively serving cached HTML migrated to caching OFF, silently. (#219)
1218 $keys = array( 'cache_enabled', 'super_cache_enabled', 'wp_cache_mod_rewrite', 'wp_cache_mobile_enabled', 'wp_cache_make_known_anon' );
1219 foreach ( $keys as $key ) {
1220 // Match `$key = 1;` / `$key = '1';` / `$key = true;` etc.
1221 if ( preg_match( '/\$' . preg_quote( $key, '/' ) . '\s*=\s*([^;]+);/', $raw, $m ) ) {
1222 $val = trim( $m[1], " \t'\"" );
1223 $out[ $key ] = in_array( strtolower( $val ), array( '1', 'true' ), true );
1224 }
1225 }
1226
1227 // Tri-state, so it cannot go through the boolean cast above:
1228 // 0 = cache everyone, 1 = skip visitors carrying any cookie,
1229 // 2 = skip logged-in visitors (WPSC's own recommended setting).
1230 // Casting collapsed 2 to false — the exact inverse of the user's
1231 // intent — which is harmless only while nothing maps the key. (#219)
1232 if ( preg_match( '/\$wp_cache_not_logged_in\s*=\s*([^;]+);/', $raw, $m ) ) {
1233 $out['wp_cache_not_logged_in'] = (int) trim( $m[1], " \t'\"" );
1234 }
1235
1236 return ! empty( $out ) ? $out : null;
1237 }
1238
1239 /** Thin wrapper so detection works before admin plugin.php is loaded. */
1240 private static function plugin_active( string $plugin ): bool {
1241 $active = (array) get_option( 'active_plugins', array() );
1242 if ( in_array( $plugin, $active, true ) ) {
1243 return true;
1244 }
1245 // Network-activated (multisite).
1246 $network = (array) get_site_option( 'active_sitewide_plugins', array() );
1247 return isset( $network[ $plugin ] );
1248 }
1249
1250 public static function plan_wpsc( array $r ): array {
1251 $plan = array(
1252 'cache' => array(
1253 // Either flag means WP Super Cache was serving: `cache_enabled`
1254 // is the master switch, `super_cache_enabled` only picks
1255 // mod_rewrite over PHP delivery.
1256 'enabled' => ! empty( $r['cache_enabled'] ) || ! empty( $r['super_cache_enabled'] ),
1257 ),
1258 );
1259 // See plan_wp_rocket: never import Separate Mobile Cache as ON — it
1260 // disables the device-blind static fast path. Flag for review instead.
1261 self::map_mobile_separate( $plan, ! empty( $r['wp_cache_mobile_enabled'] ) );
1262 return $plan;
1263 }
1264
1265 // ─────────────────────────── LiteSpeed Cache ─────────────────────
1266
1267 /**
1268 * Read LiteSpeed Cache settings into a flat `name => value` array keyed
1269 * by LiteSpeed's dotted setting names (cache, cache-mobile, optm-*,
1270 * media-*, object-*, cdn-*, …) — the shape plan_litespeed() expects.
1271 *
1272 * Storage has changed across LiteSpeed versions:
1273 * - v4+ (current): ONE option PER setting, named `litespeed.conf.<name>`
1274 * (e.g. litespeed.conf.cache, litespeed.conf.cache-mobile). There is
1275 * NO single `litespeed.conf` blob — reading that key returns null,
1276 * which is why detection used to fail on every modern install.
1277 * - v3 and earlier: a single serialized array under `litespeed.conf`
1278 * (or the legacy `litespeed-cache-conf`).
1279 * We handle all three: try the per-option family first (the common case
1280 * today), then fall back to the legacy single-blob options.
1281 *
1282 * @return array|null raw conf (name => value), or null when absent.
1283 */
1284 public static function detect_litespeed(): ?array {
1285 global $wpdb;
1286
1287 // v4+: individual `litespeed.conf.<name>` options. Pull them all and
1288 // strip the prefix so keys match what plan_litespeed() reads.
1289 // 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.
1290 $rows = $wpdb->get_results(
1291 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'litespeed.conf.%'",
1292 ARRAY_A
1293 );
1294 if ( ! empty( $rows ) ) {
1295 $conf = array();
1296 foreach ( $rows as $row ) {
1297 $name = substr( (string) $row['option_name'], strlen( 'litespeed.conf.' ) );
1298 if ( '' === $name || '_version' === $name ) {
1299 continue;
1300 }
1301 $conf[ $name ] = self::decode_litespeed_value( (string) $row['option_value'] );
1302 }
1303 if ( ! empty( $conf ) ) {
1304 return $conf;
1305 }
1306 }
1307
1308 // v3 / legacy: a single serialized array.
1309 $opt = get_option( 'litespeed.conf', null );
1310 if ( ! is_array( $opt ) ) {
1311 $opt = get_option( 'litespeed-cache-conf', null );
1312 }
1313 return is_array( $opt ) && ! empty( $opt ) ? $opt : null;
1314 }
1315
1316 /**
1317 * Decode one `litespeed.conf.*` option value.
1318 *
1319 * LiteSpeed v4+ stores its list settings as JSON strings, not
1320 * PHP-serialized arrays, so maybe_unserialize() hands the JSON straight
1321 * back as a string. plan_litespeed()'s $list() helper then splits it on
1322 * newlines — which JSON has none of — producing a ONE-element array
1323 * holding the entire blob. Every exclusion rule imported that way is
1324 * dead: the list no longer matches anything, so cart, checkout and
1325 * account pages become publicly cacheable while the import reports
1326 * success. (#217)
1327 *
1328 * Try JSON first for anything shaped like it, and fall back to
1329 * maybe_unserialize() so v3 / legacy installs keep working.
1330 */
1331 private static function decode_litespeed_value( string $raw ) {
1332 $trimmed = trim( $raw );
1333 if ( '' !== $trimmed && ( '[' === $trimmed[0] || '{' === $trimmed[0] ) ) {
1334 $decoded = json_decode( $trimmed, true );
1335 if ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) ) {
1336 return $decoded;
1337 }
1338 }
1339 return maybe_unserialize( $raw );
1340 }
1341
1342 /**
1343 * Translate LiteSpeed's `litespeed.conf` into xSpeed module patches.
1344 * LiteSpeed uses dotted keys; values are mostly bool-ish (1/0/'1').
1345 * We map only the settings that have a clean xSpeed equivalent and
1346 * leave the rest untouched so nothing is silently mis-imported.
1347 *
1348 * @param array $r raw litespeed.conf.
1349 */
1350 public static function plan_litespeed( array $r ): array {
1351 $on = static function ( $key ) use ( $r ): bool {
1352 return isset( $r[ $key ] ) && ! empty( $r[ $key ] );
1353 };
1354 // LiteSpeed list fields are stored as either a newline-delimited
1355 // string or an array. Normalize to a clean string[] either way.
1356 $list = static function ( $key ) use ( $r ): array {
1357 $v = $r[ $key ] ?? null;
1358 if ( is_string( $v ) ) {
1359 $v = preg_split( '/\r\n|\r|\n/', $v );
1360 }
1361 if ( ! is_array( $v ) ) {
1362 return array();
1363 }
1364 return array_values( array_filter( array_map( 'trim', array_map( 'strval', $v ) ) ) );
1365 };
1366 // LiteSpeed writes a regex rule bare (`^/secret-.*`); xSpeed marks one
1367 // with a leading `~` (see CacheModule's own `~wp-.*\.php` default) and
1368 // treats anything else as a literal/glob. Imported unchanged, a
1369 // LiteSpeed regex became a literal that matches nothing — the same
1370 // silent loss of protection as the JSON bug above, just narrower. Only
1371 // rules carrying an unmistakable regex metacharacter are converted, so
1372 // a plain path like `/cart` stays the literal it already is. (#217)
1373 $to_xspeed_pattern = static function ( string $rule ): string {
1374 if ( '' === $rule || '~' === $rule[0] ) {
1375 return $rule;
1376 }
1377 return preg_match( '/[\^$|]|\.\*|\.\+|\[.+\]|\\\\[dwsb]/', $rule ) ? '~' . $rule : $rule;
1378 };
1379 $set_list = static function ( array &$dest, string $dest_key, array $vals ) use ( $to_xspeed_pattern ): void {
1380 if ( in_array( $dest_key, array( 'excluded_urls', 'excluded_patterns' ), true ) ) {
1381 $vals = array_map( $to_xspeed_pattern, $vals );
1382 }
1383 if ( $vals ) {
1384 $dest[ $dest_key ] = $vals;
1385 }
1386 };
1387
1388 $patch = array();
1389
1390 // ── Page cache ────────────────────────────────────────────────
1391 $patch['cache'] = array(
1392 'enabled' => $on( 'cache' ) || $on( 'cache-priv' ),
1393 );
1394 // See plan_wp_rocket: never import Separate Mobile Cache as ON — it
1395 // disables the device-blind static fast path. Flag for review instead.
1396 self::map_mobile_separate( $patch, $on( 'cache-mobile' ) );
1397 // TTL: LiteSpeed stores cache-ttl_pub in seconds → xSpeed wants hours.
1398 if ( isset( $r['cache-ttl_pub'] ) && (int) $r['cache-ttl_pub'] > 0 ) {
1399 $patch['cache']['cache_expiry'] = max( 1, min( 720, (int) ( (int) $r['cache-ttl_pub'] / 3600 ) ) );
1400 }
1401 // Excluded URIs / cookies / user-agents / dropped query strings.
1402 $set_list( $patch['cache'], 'excluded_urls', $list( 'cache-exc' ) );
1403 $set_list( $patch['cache'], 'excluded_cookies', $list( 'cache-exc_cookies' ) );
1404 // `cache-exc_useragents`, plural — LiteSpeed's O_CACHE_EXC_USERAGENTS.
1405 // The singular spelling matched nothing, so the list always imported
1406 // empty, and an empty field looks "not configured" rather than lost. (#217)
1407 $set_list( $patch['cache'], 'bypass_user_agents', $list( 'cache-exc_useragents' ) );
1408 // LiteSpeed "drop query string" list ≈ xSpeed ignored_query_params.
1409 $set_list( $patch['cache'], 'ignored_query_params', $list( 'cache-drop_qs' ) );
1410
1411 // ── Minify / optimization ─────────────────────────────────────
1412 $patch['minify'] = array(
1413 'minify_html' => $on( 'optm-html_min' ),
1414 'minify_css' => $on( 'optm-css_min' ),
1415 'minify_js' => $on( 'optm-js_min' ),
1416 'combine_css' => $on( 'optm-css_comb' ),
1417 'combine_js' => $on( 'optm-js_comb' ),
1418 // optm-js_defer is a THREE-WAY switch, not a boolean:
1419 // 0 = OFF, 1 = Deferred, 2 = Delayed (LiteSpeed's own UI labels,
1420 // tpl/page_optm/settings_js.tpl.php). The modes replace each other,
1421 // so 2 must set delay_js INSTEAD of defer_js — the old mapping set
1422 // both, turning one LiteSpeed choice into two xSpeed transforms
1423 // that fight each other. (#217)
1424 'defer_js' => isset( $r['optm-js_defer'] ) && 1 === (int) $r['optm-js_defer'],
1425 'delay_js' => isset( $r['optm-js_defer'] ) && 2 === (int) $r['optm-js_defer'],
1426 // Async/“load CSS asynchronously” — LiteSpeed CCSS async.
1427 'async_css' => $on( 'optm-css_async' ),
1428 // Remove query strings from static resources.
1429 'remove_query_strings' => $on( 'optm-qs_rm' ),
1430 );
1431 // Defer/delay exclusion list — merge LiteSpeed's JS defer + delay
1432 // exclude lists into xSpeed's single defer_js_excluded.
1433 // `optm-js_delay_exc` does not exist in LiteSpeed. Its delay list is
1434 // optm-js_delay_inc (O_OPTM_JS_DELAY_INC) — an INCLUDE list naming the
1435 // scripts to delay, which is xSpeed's delay_js_targets, not an
1436 // exclusion. Merging it into defer_js_excluded would have inverted the
1437 // user's intent, so it maps to its own destination below. (#217)
1438 $defer_exc = $list( 'optm-js_defer_exc' );
1439 $set_list( $patch['minify'], 'defer_js_excluded', $defer_exc );
1440 $set_list( $patch['minify'], 'delay_js_targets', $list( 'optm-js_delay_inc' ) );
1441
1442 // ── Lazy load (media) ─────────────────────────────────────────
1443 $patch['lazy'] = array(
1444 'lazy_images' => $on( 'media-lazy' ),
1445 'lazy_iframes' => $on( 'media-iframe_lazy' ),
1446 // LiteSpeed has no separate HTML5-video lazy toggle; mirror the
1447 // image setting so video preload follows the same intent.
1448 'lazy_videos' => $on( 'media-lazy' ),
1449 // "Add Missing Sizes" → add_missing_dimensions (anti-CLS).
1450 'add_missing_dimensions' => $on( 'media-add_missing_sizes' ),
1451 );
1452 $set_list( $patch['lazy'], 'excluded_images', $list( 'media-lazy_exc' ) );
1453
1454 // ── Fonts ─────────────────────────────────────────────────────
1455 // LiteSpeed "Font Display Optimization" (optm-localize_style /
1456 // optm-css_font_display) → xSpeed font-display: swap.
1457 if ( $on( 'optm-css_font_display' ) || $on( 'optm-localize' ) ) {
1458 $patch['fonts'] = array( 'font_display_swap' => true );
1459 }
1460
1461 // ── Disable bloat ─────────────────────────────────────────────
1462 // Only map the one LiteSpeed "remove" toggle with a clean xSpeed
1463 // equivalent: removing the emoji + oEmbed scripts ≈ disable_oembed.
1464 // (LiteSpeed's optm-emoji_rm strips the wp-emoji + wp-embed pair.)
1465 // jQuery-migrate / dashicons / XML-RPC / RSS / REST aren't
1466 // LiteSpeed-managed, so we don't guess at them.
1467 if ( $on( 'optm-emoji_rm' ) ) {
1468 $patch['bloat'] = array( 'disable_oembed' => true );
1469 }
1470
1471 // ── Browser cache (LiteSpeed: cache-browser) ──────────────────
1472 if ( $on( 'cache-browser' ) ) {
1473 $patch['browser-cache'] = array( 'enabled' => true );
1474 if ( isset( $r['cache-ttl_browser'] ) && (int) $r['cache-ttl_browser'] > 0 ) {
1475 $patch['browser-cache']['asset_ttl'] = (int) $r['cache-ttl_browser'];
1476 }
1477 }
1478
1479 // ── Object cache ──────────────────────────────────────────────
1480 if ( $on( 'object' ) ) {
1481 $kind = isset( $r['object-kind'] ) && (int) $r['object-kind'] === 1 ? 'redis' : 'memcached';
1482 $patch['object-cache'] = array( 'backend' => $kind );
1483 if ( ! empty( $r['object-host'] ) ) {
1484 $host_key = 'redis' === $kind ? 'redis_host' : 'memcached_host';
1485 $patch['object-cache'][ $host_key ] = (string) $r['object-host'];
1486 }
1487 if ( ! empty( $r['object-port'] ) ) {
1488 $port_key = 'redis' === $kind ? 'redis_port' : 'memcached_port';
1489 $patch['object-cache'][ $port_key ] = (int) $r['object-port'];
1490 }
1491 if ( 'redis' === $kind && isset( $r['object-db_id'] ) ) {
1492 $patch['object-cache']['redis_database'] = max( 0, min( 15, (int) $r['object-db_id'] ) );
1493 }
1494 if ( ! empty( $r['object-pswd'] ) && 'redis' === $kind ) {
1495 $patch['object-cache']['redis_password'] = (string) $r['object-pswd'];
1496 }
1497 if ( ! empty( $r['object-global_groups'] ) || ! empty( $r['object-persistent'] ) ) {
1498 $patch['object-cache']['persistent'] = $on( 'object-persistent' );
1499 }
1500 }
1501
1502 // ── Image conversion (Pro Images module) ──────────────────────
1503 // LiteSpeed media-webp / next-gen image generation → xSpeed Images.
1504 if ( $on( 'img_optm-webp' ) || $on( 'media-webp' ) || $on( 'img_optm-auto' ) ) {
1505 $patch['images'] = array(
1506 'webp' => $on( 'img_optm-webp' ) || $on( 'media-webp' ),
1507 'avif' => $on( 'img_optm-avif' ),
1508 );
1509 }
1510
1511 // ── CDN ───────────────────────────────────────────────────────
1512 if ( $on( 'cdn' ) ) {
1513 $cdn_url = '';
1514 if ( ! empty( $r['cdn-mapping'] ) && is_array( $r['cdn-mapping'] ) ) {
1515 $first = $r['cdn-mapping'][0] ?? array();
1516 // LiteSpeed cdn-mapping rows use the 'url' sub-key (array form)
1517 // or a bare URL string (legacy).
1518 $cdn_url = is_array( $first ) ? (string) ( $first['url'] ?? ( $first['cdn_url'] ?? '' ) ) : (string) $first;
1519 }
1520 if ( '' !== $cdn_url ) {
1521 $patch['cdn'] = array(
1522 'enabled' => true,
1523 'cdn_url' => $cdn_url,
1524 );
1525 // LiteSpeed's O_CDN_EXC is `cdn-exc`, not `cdn-exclude`. (#217)
1526 $set_list( $patch['cdn'], 'excluded_patterns', $list( 'cdn-exc' ) );
1527 }
1528 }
1529
1530 return $patch;
1531 }
1532 }
1533