PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.8
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.8
1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-migration.php

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

884 lines 36.0 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 }
277 return $out;
278 }
279
280 /**
281 * Map a source plugin's "separate mobile cache" flag onto the cache patch
282 * WITHOUT enabling xSpeed's mobile_separate. The xSpeed static-file fast
283 * path is device-blind, so mobile_separate=ON disables it — and a source
284 * site that had the flag on very often serves identical HTML to every
285 * device (it was on by habit). Rather than silently kill the fast path on
286 * import, we keep mobile_separate off and, when the source had it on, set
287 * `mobile_separate_review` so the dashboard can prompt the user to turn it
288 * back on only if their site really differs per device.
289 * (FBS-83144 / FBS-83145)
290 *
291 * @param array $patch The plan patch (modified by reference).
292 * @param bool $source_on Whether the source plugin had mobile-separate on.
293 */
294 private static function map_mobile_separate( array &$patch, bool $source_on ): void {
295 if ( ! isset( $patch['cache'] ) || ! is_array( $patch['cache'] ) ) {
296 $patch['cache'] = array();
297 }
298 // Never import as ON; leave the fast path intact.
299 $patch['cache']['mobile_separate'] = false;
300 if ( $source_on ) {
301 $patch['cache']['mobile_separate_review'] = true;
302 }
303 }
304
305 /**
306 * Return the patch that `apply()` would write, without writing.
307 */
308 public static function preview( string $source_id ): ?array {
309 $src = self::sources()[ $source_id ] ?? null;
310 if ( null === $src ) {
311 return null;
312 }
313 $raw = call_user_func( $src['detect'] );
314 if ( ! is_array( $raw ) ) {
315 return null;
316 }
317 return call_user_func( $src['plan'], $raw );
318 }
319
320 /**
321 * Read source settings + write the translated patch. Returns the
322 * per-module write results, same shape as Recommendations::apply.
323 */
324 public static function apply( string $source_id ): array {
325 $patch = self::preview( $source_id );
326 if ( null === $patch ) {
327 return array();
328 }
329 $results = array();
330 foreach ( $patch as $slug => $values ) {
331 if ( ! is_string( $slug ) || ! is_array( $values ) ) {
332 continue;
333 }
334 // Import only the settings that are actually enabled / non-empty in
335 // the source. A plan patch emits every mapped field including the
336 // ones the source turned OFF; merging those `false`/empty values
337 // would silently DISABLE settings the user already had on in xSpeed.
338 // Migration is additive — it never clobbers existing config with a
339 // source's disabled value. (FBS-82449)
340 $meaningful = self::meaningful_values( $values );
341 if ( empty( $meaningful ) ) {
342 continue;
343 }
344 $option = 'xspeed_module_' . $slug;
345 $cur = (array) get_option( $option, array() );
346 $next = array_merge( $cur, $meaningful );
347 $ok = update_option( $option, $next );
348 $results[ $slug ] = array(
349 'ok' => (bool) $ok,
350 'applied' => array_keys( $meaningful ),
351 );
352 }
353 if ( ! empty( $results ) ) {
354 self::mark_imported( $source_id );
355 }
356 return $results;
357 }
358
359 // ─────────────────────────── WP Rocket ───────────────────────────
360
361 public static function detect_wp_rocket(): ?array {
362 $opt = get_option( 'wp_rocket_settings', null );
363 return is_array( $opt ) ? $opt : null;
364 }
365
366 /**
367 * Translate WP Rocket's `wp_rocket_settings` array into our module
368 * settings. Only safe-to-port booleans + counts; behaviorally
369 * different toggles (Critical CSS, RUCSS) skip — Pro handles those.
370 *
371 * @param array $r raw wp_rocket_settings.
372 */
373 public static function plan_wp_rocket( array $r ): array {
374 $patch = array();
375 // Page caching.
376 $patch['cache'] = array(
377 'enabled' => ! empty( $r['cache_logged_user'] ) || ! isset( $r['cache_logged_user'] ),
378 // The Cache module's TTL setting is `cache_expiry` (hours), NOT
379 // `expiry_hours` — the latter is a dead key nothing reads, so the
380 // imported lifetime was silently dropped. Clamp to the same 1–720h
381 // range the Cache schema + LiteSpeed importer use. (FBS-83144)
382 'cache_expiry' => isset( $r['purge_cron_interval'] ) ? max( 1, min( 720, (int) ( (int) $r['purge_cron_interval'] / 3600 ) ) ) : 24,
383 );
384 // Excluded URLs / cookies — both are arrays of strings in WP Rocket.
385 if ( ! empty( $r['cache_reject_uri'] ) && is_array( $r['cache_reject_uri'] ) ) {
386 $patch['cache']['excluded_urls'] = array_values( array_filter( array_map( 'strval', $r['cache_reject_uri'] ) ) );
387 }
388 if ( ! empty( $r['cache_reject_cookies'] ) && is_array( $r['cache_reject_cookies'] ) ) {
389 $patch['cache']['excluded_cookies'] = array_values( array_filter( array_map( 'strval', $r['cache_reject_cookies'] ) ) );
390 }
391
392 // Minify.
393 $patch['minify'] = array(
394 'minify_html' => ! empty( $r['minify_html'] ),
395 'minify_css' => ! empty( $r['minify_css'] ),
396 'minify_js' => ! empty( $r['minify_js'] ),
397 'combine_css' => ! empty( $r['minify_concatenate_css'] ),
398 'combine_js' => ! empty( $r['minify_concatenate_js'] ),
399 'defer_js' => ! empty( $r['defer_all_js'] ),
400 );
401
402 // Lazy load.
403 $patch['lazy'] = array(
404 'lazy_images' => ! empty( $r['lazyload'] ),
405 'lazy_iframes' => ! empty( $r['lazyload_iframes'] ),
406 'lazy_videos' => ! empty( $r['lazyload_youtube'] ),
407 );
408
409 // Separate Mobile Cache — do NOT import this as ON. WP Rocket's
410 // "separate cache files for mobile" is frequently left on by habit even
411 // when the site serves identical HTML to every device, and xSpeed's
412 // static-file fast path is device-blind — enabling mobile_separate
413 // DISABLES it, silently dropping the site from HIT (nginx) to HIT (php).
414 // Instead, keep the fast path (mobile_separate stays false) and flag it
415 // for review so the dashboard can prompt the user to re-enable it only
416 // if their site really differs per device. (FBS-83144 / FBS-83145)
417 self::map_mobile_separate( $patch, ! empty( $r['do_caching_mobile_files'] ) );
418
419 // Preloader.
420 if ( ! empty( $r['manual_preload'] ) || ! empty( $r['sitemap_preload'] ) ) {
421 $patch['preloader'] = array(
422 'enabled' => true,
423 'schedule' => 'daily',
424 );
425 if ( ! empty( $r['sitemap_preload_url'] ) && is_array( $r['sitemap_preload_url'] ) ) {
426 $patch['preloader']['sitemap_urls'] = array_values( array_filter( array_map( 'strval', $r['sitemap_preload_url'] ) ) );
427 }
428 }
429
430 // CDN — WP Rocket stores CDN hosts in cdn_cnames (array).
431 if ( ! empty( $r['cdn'] ) && ! empty( $r['cdn_cnames'] ) && is_array( $r['cdn_cnames'] ) ) {
432 $first = (string) ( $r['cdn_cnames'][0] ?? '' );
433 if ( '' !== $first ) {
434 $patch['cdn'] = array(
435 'enabled' => true,
436 'cdn_url' => $first,
437 );
438 }
439 }
440
441 return $patch;
442 }
443
444 // ─────────────────────────── W3 Total Cache ──────────────────────
445
446 public static function detect_w3tc(): ?array {
447 // W3 Total Cache does NOT store its config in the options table — it
448 // writes a PHP file at wp-content/w3tc-config/master.php whose body
449 // is a short PHP guard followed by a JSON blob of dotted-key settings
450 // (pgcache.enabled, minify.html.enable, …). Reading w3tc_config /
451 // w3tc_master_settings options always returned null, so detection
452 // failed on every install. Read + parse the config file instead.
453 $cfg = self::read_w3tc_config_file();
454 if ( is_array( $cfg ) && ! empty( $cfg ) ) {
455 return $cfg;
456 }
457 // Defensive fallback for any build that did persist an options blob.
458 $opt = get_option( 'w3tc_config', null );
459 if ( ! is_array( $opt ) ) {
460 $opt = get_option( 'w3tc_master_settings', null );
461 }
462 return is_array( $opt ) ? $opt : null;
463 }
464
465 /**
466 * Parse W3TC's master config file into a flat dotted-key array.
467 * Format: a short PHP guard (a php-open, exit, php-close) immediately
468 * followed by a JSON object. We strip everything up to and including the
469 * PHP closing tag, then JSON-decode the remainder.
470 *
471 * @return array|null parsed config, or null if the file is missing/unreadable.
472 */
473 private static function read_w3tc_config_file(): ?array {
474 if ( ! defined( 'WP_CONTENT_DIR' ) ) {
475 return null;
476 }
477 $path = WP_CONTENT_DIR . '/w3tc-config/master.php';
478 if ( ! is_readable( $path ) ) {
479 return null;
480 }
481 $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.
482 if ( false === $raw || '' === $raw ) {
483 return null;
484 }
485 // Drop the leading PHP guard and decode the JSON tail. The pattern
486 // matches up to the first PHP closing tag; built from a char-code so
487 // no literal close tag appears in this source file.
488 $close_tag = '?' . '>';
489 $json = preg_replace( '/^.*?' . preg_quote( $close_tag, '/' ) . '/s', '', $raw );
490 $cfg = json_decode( trim( (string) $json ), true );
491 return is_array( $cfg ) ? $cfg : null;
492 }
493
494 public static function plan_w3tc( array $r ): array {
495 $patch = array();
496 $patch['cache'] = array(
497 'enabled' => ! empty( $r['pgcache.enabled'] ),
498 // Cache module reads `cache_expiry` (hours), not the dead
499 // `expiry_hours` key — see plan_wp_rocket. (FBS-83144)
500 'cache_expiry' => isset( $r['pgcache.lifetime'] ) ? max( 1, min( 720, (int) ( (int) $r['pgcache.lifetime'] / 3600 ) ) ) : 24,
501 );
502 if ( ! empty( $r['pgcache.reject.uri'] ) && is_array( $r['pgcache.reject.uri'] ) ) {
503 $patch['cache']['excluded_urls'] = array_values( array_filter( array_map( 'strval', $r['pgcache.reject.uri'] ) ) );
504 }
505
506 $patch['minify'] = array(
507 'minify_html' => ! empty( $r['minify.html.enable'] ),
508 'minify_css' => ! empty( $r['minify.css.enable'] ),
509 'minify_js' => ! empty( $r['minify.js.enable'] ),
510 );
511
512 if ( ! empty( $r['objectcache.enabled'] ) && ! empty( $r['objectcache.engine'] ) ) {
513 $patch['object-cache'] = array(
514 'backend' => 'memcached' === $r['objectcache.engine'] ? 'memcached' : 'redis',
515 );
516 if ( ! empty( $r['objectcache.servers'] ) && is_array( $r['objectcache.servers'] ) ) {
517 $first = (string) ( $r['objectcache.servers'][0] ?? '' );
518 if ( false !== strpos( $first, ':' ) ) {
519 [ $host, $port ] = explode( ':', $first, 2 );
520 if ( 'redis' === ( $patch['object-cache']['backend'] ?? '' ) ) {
521 $patch['object-cache']['redis_host'] = $host;
522 $patch['object-cache']['redis_port'] = (int) $port;
523 } else {
524 $patch['object-cache']['memcached_host'] = $host;
525 $patch['object-cache']['memcached_port'] = (int) $port;
526 }
527 }
528 }
529 }
530
531 if ( ! empty( $r['browsercache.enabled'] ) ) {
532 $patch['browser-cache'] = array(
533 'enabled' => true,
534 );
535 }
536
537 return $patch;
538 }
539
540 // ─────────────────────────── WP Super Cache ──────────────────────
541
542 public static function detect_wpsc(): ?array {
543 // WP Super Cache stores its settings as PHP globals in
544 // wp-content/wp-cache-config.php (NOT the options table — the old
545 // get_option('wp_cache_enabled') reads always returned null). Parse
546 // the config file for the globals plan_wpsc() needs. If the file
547 // doesn't exist yet (plugin active but never configured), fall back
548 // to a minimal "active" marker so the source still appears in the UI
549 // and a default import is possible.
550 $cfg = self::read_wpsc_config_file();
551 if ( is_array( $cfg ) && ! empty( $cfg ) ) {
552 return $cfg;
553 }
554 if ( self::plugin_active( 'wp-super-cache/wp-cache.php' ) ) {
555 // Active but unconfigured — expose the on/off intent only.
556 return array( 'wp_cache_enabled' => defined( 'WPCACHEHOME' ) );
557 }
558 return null;
559 }
560
561 /**
562 * Parse the WP Super Cache config file for the globals we map. The file
563 * is plain PHP assigning `$wp_cache_* = …;` lines; we extract them with a
564 * regex rather than including the file (including it would define
565 * constants / run code in our request).
566 *
567 * @return array|null name => value for the recognised globals, or null.
568 */
569 private static function read_wpsc_config_file(): ?array {
570 if ( ! defined( 'WP_CONTENT_DIR' ) ) {
571 return null;
572 }
573 $path = WP_CONTENT_DIR . '/wp-cache-config.php';
574 if ( ! is_readable( $path ) ) {
575 return null;
576 }
577 $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.
578 if ( false === $raw || '' === $raw ) {
579 return null;
580 }
581 $out = array();
582 $keys = array( 'wp_cache_enabled', 'wp_cache_mod_rewrite', 'wp_cache_mobile_enabled', 'wp_cache_not_logged_in', 'wp_cache_make_known_anon' );
583 foreach ( $keys as $key ) {
584 // Match `$key = 1;` / `$key = '1';` / `$key = true;` etc.
585 if ( preg_match( '/\$' . preg_quote( $key, '/' ) . '\s*=\s*([^;]+);/', $raw, $m ) ) {
586 $val = trim( $m[1], " \t'\"" );
587 $out[ $key ] = in_array( strtolower( $val ), array( '1', 'true' ), true );
588 }
589 }
590 return ! empty( $out ) ? $out : null;
591 }
592
593 /** Thin wrapper so detection works before admin plugin.php is loaded. */
594 private static function plugin_active( string $plugin ): bool {
595 $active = (array) get_option( 'active_plugins', array() );
596 if ( in_array( $plugin, $active, true ) ) {
597 return true;
598 }
599 // Network-activated (multisite).
600 $network = (array) get_site_option( 'active_sitewide_plugins', array() );
601 return isset( $network[ $plugin ] );
602 }
603
604 public static function plan_wpsc( array $r ): array {
605 $plan = array(
606 'cache' => array(
607 'enabled' => ! empty( $r['wp_cache_enabled'] ),
608 ),
609 );
610 // See plan_wp_rocket: never import Separate Mobile Cache as ON — it
611 // disables the device-blind static fast path. Flag for review instead.
612 self::map_mobile_separate( $plan, ! empty( $r['wp_cache_mobile_enabled'] ) );
613 return $plan;
614 }
615
616 // ─────────────────────────── LiteSpeed Cache ─────────────────────
617
618 /**
619 * Read LiteSpeed Cache settings into a flat `name => value` array keyed
620 * by LiteSpeed's dotted setting names (cache, cache-mobile, optm-*,
621 * media-*, object-*, cdn-*, …) — the shape plan_litespeed() expects.
622 *
623 * Storage has changed across LiteSpeed versions:
624 * - v4+ (current): ONE option PER setting, named `litespeed.conf.<name>`
625 * (e.g. litespeed.conf.cache, litespeed.conf.cache-mobile). There is
626 * NO single `litespeed.conf` blob — reading that key returns null,
627 * which is why detection used to fail on every modern install.
628 * - v3 and earlier: a single serialized array under `litespeed.conf`
629 * (or the legacy `litespeed-cache-conf`).
630 * We handle all three: try the per-option family first (the common case
631 * today), then fall back to the legacy single-blob options.
632 *
633 * @return array|null raw conf (name => value), or null when absent.
634 */
635 public static function detect_litespeed(): ?array {
636 global $wpdb;
637
638 // v4+: individual `litespeed.conf.<name>` options. Pull them all and
639 // strip the prefix so keys match what plan_litespeed() reads.
640 // 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.
641 $rows = $wpdb->get_results(
642 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE 'litespeed.conf.%'",
643 ARRAY_A
644 );
645 if ( ! empty( $rows ) ) {
646 $conf = array();
647 foreach ( $rows as $row ) {
648 $name = substr( (string) $row['option_name'], strlen( 'litespeed.conf.' ) );
649 if ( '' === $name || '_version' === $name ) {
650 continue;
651 }
652 $conf[ $name ] = self::decode_litespeed_value( (string) $row['option_value'] );
653 }
654 if ( ! empty( $conf ) ) {
655 return $conf;
656 }
657 }
658
659 // v3 / legacy: a single serialized array.
660 $opt = get_option( 'litespeed.conf', null );
661 if ( ! is_array( $opt ) ) {
662 $opt = get_option( 'litespeed-cache-conf', null );
663 }
664 return is_array( $opt ) && ! empty( $opt ) ? $opt : null;
665 }
666
667 /**
668 * Decode one `litespeed.conf.*` option value.
669 *
670 * LiteSpeed v4+ stores its list settings as JSON strings, not
671 * PHP-serialized arrays, so maybe_unserialize() hands the JSON straight
672 * back as a string. plan_litespeed()'s $list() helper then splits it on
673 * newlines — which JSON has none of — producing a ONE-element array
674 * holding the entire blob. Every exclusion rule imported that way is
675 * dead: the list no longer matches anything, so cart, checkout and
676 * account pages become publicly cacheable while the import reports
677 * success. (#217)
678 *
679 * Try JSON first for anything shaped like it, and fall back to
680 * maybe_unserialize() so v3 / legacy installs keep working.
681 */
682 private static function decode_litespeed_value( string $raw ) {
683 $trimmed = trim( $raw );
684 if ( '' !== $trimmed && ( '[' === $trimmed[0] || '{' === $trimmed[0] ) ) {
685 $decoded = json_decode( $trimmed, true );
686 if ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) ) {
687 return $decoded;
688 }
689 }
690 return maybe_unserialize( $raw );
691 }
692
693 /**
694 * Translate LiteSpeed's `litespeed.conf` into xSpeed module patches.
695 * LiteSpeed uses dotted keys; values are mostly bool-ish (1/0/'1').
696 * We map only the settings that have a clean xSpeed equivalent and
697 * leave the rest untouched so nothing is silently mis-imported.
698 *
699 * @param array $r raw litespeed.conf.
700 */
701 public static function plan_litespeed( array $r ): array {
702 $on = static function ( $key ) use ( $r ): bool {
703 return isset( $r[ $key ] ) && ! empty( $r[ $key ] );
704 };
705 // LiteSpeed list fields are stored as either a newline-delimited
706 // string or an array. Normalize to a clean string[] either way.
707 $list = static function ( $key ) use ( $r ): array {
708 $v = $r[ $key ] ?? null;
709 if ( is_string( $v ) ) {
710 $v = preg_split( '/\r\n|\r|\n/', $v );
711 }
712 if ( ! is_array( $v ) ) {
713 return array();
714 }
715 return array_values( array_filter( array_map( 'trim', array_map( 'strval', $v ) ) ) );
716 };
717 // LiteSpeed writes a regex rule bare (`^/secret-.*`); xSpeed marks one
718 // with a leading `~` (see CacheModule's own `~wp-.*\.php` default) and
719 // treats anything else as a literal/glob. Imported unchanged, a
720 // LiteSpeed regex became a literal that matches nothing — the same
721 // silent loss of protection as the JSON bug above, just narrower. Only
722 // rules carrying an unmistakable regex metacharacter are converted, so
723 // a plain path like `/cart` stays the literal it already is. (#217)
724 $to_xspeed_pattern = static function ( string $rule ): string {
725 if ( '' === $rule || '~' === $rule[0] ) {
726 return $rule;
727 }
728 return preg_match( '/[\^$|]|\.\*|\.\+|\[.+\]|\\\\[dwsb]/', $rule ) ? '~' . $rule : $rule;
729 };
730 $set_list = static function ( array &$dest, string $dest_key, array $vals ) use ( $to_xspeed_pattern ): void {
731 if ( in_array( $dest_key, array( 'excluded_urls', 'excluded_patterns' ), true ) ) {
732 $vals = array_map( $to_xspeed_pattern, $vals );
733 }
734 if ( $vals ) {
735 $dest[ $dest_key ] = $vals;
736 }
737 };
738
739 $patch = array();
740
741 // ── Page cache ────────────────────────────────────────────────
742 $patch['cache'] = array(
743 'enabled' => $on( 'cache' ) || $on( 'cache-priv' ),
744 );
745 // See plan_wp_rocket: never import Separate Mobile Cache as ON — it
746 // disables the device-blind static fast path. Flag for review instead.
747 self::map_mobile_separate( $patch, $on( 'cache-mobile' ) );
748 // TTL: LiteSpeed stores cache-ttl_pub in seconds → xSpeed wants hours.
749 if ( isset( $r['cache-ttl_pub'] ) && (int) $r['cache-ttl_pub'] > 0 ) {
750 $patch['cache']['cache_expiry'] = max( 1, min( 720, (int) ( (int) $r['cache-ttl_pub'] / 3600 ) ) );
751 }
752 // Excluded URIs / cookies / user-agents / dropped query strings.
753 $set_list( $patch['cache'], 'excluded_urls', $list( 'cache-exc' ) );
754 $set_list( $patch['cache'], 'excluded_cookies', $list( 'cache-exc_cookies' ) );
755 // `cache-exc_useragents`, plural — LiteSpeed's O_CACHE_EXC_USERAGENTS.
756 // The singular spelling matched nothing, so the list always imported
757 // empty, and an empty field looks "not configured" rather than lost. (#217)
758 $set_list( $patch['cache'], 'bypass_user_agents', $list( 'cache-exc_useragents' ) );
759 // LiteSpeed "drop query string" list ≈ xSpeed ignored_query_params.
760 $set_list( $patch['cache'], 'ignored_query_params', $list( 'cache-drop_qs' ) );
761
762 // ── Minify / optimization ─────────────────────────────────────
763 $patch['minify'] = array(
764 'minify_html' => $on( 'optm-html_min' ),
765 'minify_css' => $on( 'optm-css_min' ),
766 'minify_js' => $on( 'optm-js_min' ),
767 'combine_css' => $on( 'optm-css_comb' ),
768 'combine_js' => $on( 'optm-js_comb' ),
769 // optm-js_defer is a THREE-WAY switch, not a boolean:
770 // 0 = OFF, 1 = Deferred, 2 = Delayed (LiteSpeed's own UI labels,
771 // tpl/page_optm/settings_js.tpl.php). The modes replace each other,
772 // so 2 must set delay_js INSTEAD of defer_js — the old mapping set
773 // both, turning one LiteSpeed choice into two xSpeed transforms
774 // that fight each other. (#217)
775 'defer_js' => isset( $r['optm-js_defer'] ) && 1 === (int) $r['optm-js_defer'],
776 'delay_js' => isset( $r['optm-js_defer'] ) && 2 === (int) $r['optm-js_defer'],
777 // Async/“load CSS asynchronously” — LiteSpeed CCSS async.
778 'async_css' => $on( 'optm-css_async' ),
779 // Remove query strings from static resources.
780 'remove_query_strings' => $on( 'optm-qs_rm' ),
781 );
782 // Defer/delay exclusion list — merge LiteSpeed's JS defer + delay
783 // exclude lists into xSpeed's single defer_js_excluded.
784 // `optm-js_delay_exc` does not exist in LiteSpeed. Its delay list is
785 // optm-js_delay_inc (O_OPTM_JS_DELAY_INC) — an INCLUDE list naming the
786 // scripts to delay, which is xSpeed's delay_js_targets, not an
787 // exclusion. Merging it into defer_js_excluded would have inverted the
788 // user's intent, so it maps to its own destination below. (#217)
789 $defer_exc = $list( 'optm-js_defer_exc' );
790 $set_list( $patch['minify'], 'defer_js_excluded', $defer_exc );
791 $set_list( $patch['minify'], 'delay_js_targets', $list( 'optm-js_delay_inc' ) );
792
793 // ── Lazy load (media) ─────────────────────────────────────────
794 $patch['lazy'] = array(
795 'lazy_images' => $on( 'media-lazy' ),
796 'lazy_iframes' => $on( 'media-iframe_lazy' ),
797 // LiteSpeed has no separate HTML5-video lazy toggle; mirror the
798 // image setting so video preload follows the same intent.
799 'lazy_videos' => $on( 'media-lazy' ),
800 // "Add Missing Sizes" → add_missing_dimensions (anti-CLS).
801 'add_missing_dimensions' => $on( 'media-add_missing_sizes' ),
802 );
803 $set_list( $patch['lazy'], 'excluded_images', $list( 'media-lazy_exc' ) );
804
805 // ── Fonts ─────────────────────────────────────────────────────
806 // LiteSpeed "Font Display Optimization" (optm-localize_style /
807 // optm-css_font_display) → xSpeed font-display: swap.
808 if ( $on( 'optm-css_font_display' ) || $on( 'optm-localize' ) ) {
809 $patch['fonts'] = array( 'font_display_swap' => true );
810 }
811
812 // ── Disable bloat ─────────────────────────────────────────────
813 // Only map the one LiteSpeed "remove" toggle with a clean xSpeed
814 // equivalent: removing the emoji + oEmbed scripts ≈ disable_oembed.
815 // (LiteSpeed's optm-emoji_rm strips the wp-emoji + wp-embed pair.)
816 // jQuery-migrate / dashicons / XML-RPC / RSS / REST aren't
817 // LiteSpeed-managed, so we don't guess at them.
818 if ( $on( 'optm-emoji_rm' ) ) {
819 $patch['bloat'] = array( 'disable_oembed' => true );
820 }
821
822 // ── Browser cache (LiteSpeed: cache-browser) ──────────────────
823 if ( $on( 'cache-browser' ) ) {
824 $patch['browser-cache'] = array( 'enabled' => true );
825 if ( isset( $r['cache-ttl_browser'] ) && (int) $r['cache-ttl_browser'] > 0 ) {
826 $patch['browser-cache']['asset_ttl'] = (int) $r['cache-ttl_browser'];
827 }
828 }
829
830 // ── Object cache ──────────────────────────────────────────────
831 if ( $on( 'object' ) ) {
832 $kind = isset( $r['object-kind'] ) && (int) $r['object-kind'] === 1 ? 'redis' : 'memcached';
833 $patch['object-cache'] = array( 'backend' => $kind );
834 if ( ! empty( $r['object-host'] ) ) {
835 $host_key = 'redis' === $kind ? 'redis_host' : 'memcached_host';
836 $patch['object-cache'][ $host_key ] = (string) $r['object-host'];
837 }
838 if ( ! empty( $r['object-port'] ) ) {
839 $port_key = 'redis' === $kind ? 'redis_port' : 'memcached_port';
840 $patch['object-cache'][ $port_key ] = (int) $r['object-port'];
841 }
842 if ( 'redis' === $kind && isset( $r['object-db_id'] ) ) {
843 $patch['object-cache']['redis_database'] = max( 0, min( 15, (int) $r['object-db_id'] ) );
844 }
845 if ( ! empty( $r['object-pswd'] ) && 'redis' === $kind ) {
846 $patch['object-cache']['redis_password'] = (string) $r['object-pswd'];
847 }
848 if ( ! empty( $r['object-global_groups'] ) || ! empty( $r['object-persistent'] ) ) {
849 $patch['object-cache']['persistent'] = $on( 'object-persistent' );
850 }
851 }
852
853 // ── Image conversion (Pro Images module) ──────────────────────
854 // LiteSpeed media-webp / next-gen image generation → xSpeed Images.
855 if ( $on( 'img_optm-webp' ) || $on( 'media-webp' ) || $on( 'img_optm-auto' ) ) {
856 $patch['images'] = array(
857 'webp' => $on( 'img_optm-webp' ) || $on( 'media-webp' ),
858 'avif' => $on( 'img_optm-avif' ),
859 );
860 }
861
862 // ── CDN ───────────────────────────────────────────────────────
863 if ( $on( 'cdn' ) ) {
864 $cdn_url = '';
865 if ( ! empty( $r['cdn-mapping'] ) && is_array( $r['cdn-mapping'] ) ) {
866 $first = $r['cdn-mapping'][0] ?? array();
867 // LiteSpeed cdn-mapping rows use the 'url' sub-key (array form)
868 // or a bare URL string (legacy).
869 $cdn_url = is_array( $first ) ? (string) ( $first['url'] ?? ( $first['cdn_url'] ?? '' ) ) : (string) $first;
870 }
871 if ( '' !== $cdn_url ) {
872 $patch['cdn'] = array(
873 'enabled' => true,
874 'cdn_url' => $cdn_url,
875 );
876 // LiteSpeed's O_CDN_EXC is `cdn-exc`, not `cdn-exclude`. (#217)
877 $set_list( $patch['cdn'], 'excluded_patterns', $list( 'cdn-exc' ) );
878 }
879 }
880
881 return $patch;
882 }
883 }
884