| 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 |
|