PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 All 36 releases
← All changes | includes/class-object-cache.php +249 -31 1.3.7 → 1.4.2 View file →
@@ -46,9 +46,9 @@
46 46 * persistent: bool, // ours is installed AND persisting
47 47 * class_available: array<string,bool>
48 48 * }
49 49 */
50 - public static function detect(): array {
50 + public static function detect( bool $with_owner = false ): array {
51 51 $dropin = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : '';
52 52 $has_drop_in = '' !== $dropin && file_exists( $dropin );
53 53 $label = $has_drop_in ? self::sniff_drop_in_label( $dropin ) : '';
54 54 $ext_in_use = function_exists( 'wp_using_ext_object_cache' ) ? (bool) wp_using_ext_object_cache() : false;
@@ -116,12 +116,89 @@
116 116 'wp_cache_active' => $ext_in_use,
117 117 'degraded' => $degraded,
118 118 'persistent' => $persistent,
119 119 'class_available' => $class_available,
120 + // Who owns a foreign drop-in and how a switch would go, so every
121 + // surface can offer it (or say why not) the same way. (#686)
122 + // Only on request: it reads plugin folders, and module status
123 + // calls detect() on dashboard loads.
124 + 'owner' => $with_owner && $has_drop_in && ! self::is_our_dropin_present() ? Object_Cache_Takeover::owner() : null,
125 + // The plugin a switch replaced, which Disable can put back.
126 + 'previous_owner' => self::previous_owner(),
120 127 );
121 128 }
122 129
123 130 /**
131 + * Label of the plugin xSpeed switched from, while ours is installed.
132 + *
133 + * @return array{label:string}|null
134 + */
135 + private static function previous_owner(): ?array {
136 + if ( ! self::is_our_dropin_present() ) {
137 + return null;
138 + }
139 + $record = Object_Cache_Takeover::record();
140 + return null === $record ? null : array( 'label' => $record['label'] );
141 + }
142 +
143 + /** A drop-in that is not ours sits in wp-content. */
144 + private static function foreign_dropin_present(): bool {
145 + $dropin = defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/object-cache.php' : '';
146 + return '' !== $dropin && ( file_exists( $dropin ) || is_link( $dropin ) ) && ! self::is_our_dropin_present();
147 + }
148 +
149 + /** Our drop-in is the object cache this request is running on. */
150 + private static function our_dropin_is_live(): bool {
151 + return isset( $GLOBALS['wp_object_cache'] ) && $GLOBALS['wp_object_cache'] instanceof \XSpeed_Object_Cache;
152 + }
153 +
154 + /**
155 + * Delete this site's keys from Redis, the same `salt:*` scope the
156 + * drop-in's flush uses. For when the drop-in is not loaded in this
157 + * request, so wp_cache_flush() would flush someone else's cache or none.
158 + * Memcached has no key enumeration; its namespace generation covers it.
159 + *
160 + * @param array $opts Settings array.
161 + * @return int Keys deleted, or -1 when nothing could be done.
162 + */
163 + public static function purge_namespace( array $opts ): int {
164 + if ( 'redis' !== (string) ( $opts['backend'] ?? 'redis' ) ) {
165 + return -1;
166 + }
167 + $salt = self::effective_salt( $opts );
168 + if ( '' === $salt ) {
169 + return -1;
170 + }
171 + $client = new Redis_Client(
172 + self::str( $opts, 'redis_host', '127.0.0.1' ),
173 + self::int( $opts, 'redis_port', 6379 ),
174 + (float) self::int( $opts, 'connection_timeout', 1 ),
175 + false
176 + );
177 + if ( ! $client->connect() ) {
178 + return -1;
179 + }
180 + $user = self::str( $opts, 'redis_user', '' );
181 + $pass = self::str( $opts, 'redis_password', '' );
182 + if ( ( '' !== $pass || '' !== $user ) && false === $client->auth( $pass, $user ) ) {
183 + $client->close();
184 + return -1;
185 + }
186 + $db = self::int( $opts, 'redis_database', 0 );
187 + if ( $db > 0 ) {
188 + $client->select( $db );
189 + }
190 + $pattern = str_replace(
191 + array( '\\', '*', '?', '[', ']' ),
192 + array( '\\\\', '\\*', '\\?', '\\[', '\\]' ),
193 + $salt
194 + ) . ':*';
195 + $deleted = $client->delete_by_pattern( $pattern );
196 + $client->close();
197 + return $deleted;
198 + }
199 +
200 + /**
124 201 * Flush whatever cache backend is wired up. Works against any
125 202 * compliant drop-in OR the WP default in-memory cache.
126 203 */
127 204 public static function flush(): bool {
@@ -151,10 +228,19 @@
151 228 $prefix = self::effective_salt( $opts );
152 229 $timeout = self::int( $opts, 'connection_timeout', 1 );
153 230 $persist = ! empty( $opts['persistent'] );
154 231
155 - $lines[] = "define( 'WP_REDIS_HOST', '" . self::esc( $host ) . "' );";
156 - $lines[] = "define( 'WP_REDIS_PORT', " . $port . ' );';
232 + // A socket is written the way Redis Object Cache expects one,
233 + // scheme plus path, so the snippet works with that plugin too.
234 + // xSpeed's drop-in reads the same pair. (#566)
235 + $socket = Redis_Client::socket_path( $host );
236 + if ( '' !== $socket ) {
237 + $lines[] = "define( 'WP_REDIS_SCHEME', 'unix' );";
238 + $lines[] = "define( 'WP_REDIS_PATH', '" . self::esc( $socket ) . "' );";
239 + } else {
240 + $lines[] = "define( 'WP_REDIS_HOST', '" . self::esc( $host ) . "' );";
241 + $lines[] = "define( 'WP_REDIS_PORT', " . $port . ' );';
242 + }
157 243 // Emit the ACL username only when set (Redis 6+). The drop-in
158 244 // reads it; an empty user keeps the legacy default-user behavior.
159 245 if ( '' !== $user ) {
160 246 $lines[] = "define( 'WP_REDIS_USER', '" . self::esc( $user ) . "' );";
@@ -260,13 +346,21 @@
260 346 $timeout = self::int( $opts, 'connection_timeout', 1 );
261 347 $user = self::str( $opts, 'redis_user', '' );
262 348 $pass = self::str( $opts, 'redis_password', '' );
263 349 $db = self::int( $opts, 'redis_database', 0 );
350 + // A socket path has no port. phpredis opens a socket only when the
351 + // port is below 1, and the messages should name the path the user
352 + // typed rather than "path:6379". (#566)
353 + $socket = Redis_Client::socket_path( $host );
354 + $where = '' !== $socket ? $socket : "{$host}:{$port}";
264 355
265 356 if ( class_exists( '\\Redis' ) ) {
266 - $redis = new \Redis();
267 - if ( ! @$redis->connect( $host, $port, $timeout ) ) {
268 - return self::test_result( false, $backend, "Could not connect to Redis at {$host}:{$port}." );
357 + $redis = new \Redis();
358 + $connected = '' !== $socket
359 + ? @$redis->connect( $socket, 0, $timeout )
360 + : @$redis->connect( $host, $port, $timeout );
361 + if ( ! $connected ) {
362 + return self::test_result( false, $backend, "Could not connect to Redis at {$where}." );
269 363 }
270 364 // Redis 6+ ACL: when a username is set, authenticate as that user
271 365 // (phpredis ≥ 5.3 accepts ['user'=>..,'pass'=>..]); otherwise keep
272 366 // the legacy password-only form that authenticates as `default`.
@@ -279,9 +373,9 @@
279 373 }
280 374 $pong = @$redis->ping();
281 375 $ok = ( '+PONG' === $pong || true === $pong || 'PONG' === $pong );
282 376 if ( ! $ok ) {
283 - return self::test_result( false, $backend, "Redis at {$host}:{$port} did not respond to PING.", $start );
377 + return self::test_result( false, $backend, "Redis at {$where} did not respond to PING.", $start );
284 378 }
285 379 // Write-verification: PING only proves auth, not that the user can
286 380 // STORE data. ACL-namespaced hosts (xCloud) restrict a user to a
287 381 // key pattern (~redis:<id>:*); a SET outside it is NOPERM-denied and
@@ -293,17 +387,17 @@
293 387 $set = @$redis->set( $probe, '1', 5 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- NOPERM/denied is the negative answer we report, not a fatal.
294 388 $got = @$redis->get( $probe ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- same.
295 389 @$redis->del( $probe ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort cleanup.
296 390 if ( ! $set || '1' !== (string) $got ) {
297 - return self::test_result( false, $backend, self::write_denied_message( $opts, $host, $port ), $start );
391 + return self::test_result( false, $backend, self::write_denied_message( $opts, $where ), $start );
298 392 }
299 - return self::test_result( true, $backend, self::with_prefix_advisory( "Connected to Redis at {$host}:{$port} (phpredis).", $opts ), $start );
393 + return self::test_result( true, $backend, self::with_prefix_advisory( "Connected to Redis at {$where} (phpredis).", $opts ), $start );
300 394 }
301 395
302 396 // Pure-PHP fallback — our own client, zero dependencies.
303 397 $rc = new Redis_Client( $host, $port, (float) $timeout, false );
304 398 if ( ! $rc->connect() ) {
305 - return self::test_result( false, $backend, "Could not connect to Redis at {$host}:{$port}." );
399 + return self::test_result( false, $backend, "Could not connect to Redis at {$where}." );
306 400 }
307 401 // Authenticate when a user OR a password is set. Gating on password
308 402 // alone skipped auth for the "ACL user + empty password" case, which
309 403 // then failed later at PING with a misleading message. (FBS-83118 OC-1)
@@ -317,9 +411,9 @@
317 411 $pong = $rc->ping();
318 412 $ok = ( is_string( $pong ) && false !== stripos( $pong, 'PONG' ) );
319 413 if ( ! $ok ) {
320 414 $rc->close();
321 - return self::test_result( false, $backend, "Redis at {$host}:{$port} did not respond to PING.", $start );
415 + return self::test_result( false, $backend, "Redis at {$where} did not respond to PING.", $start );
322 416 }
323 417 // Write-verification round-trip — same rationale as the phpredis path
324 418 // above. (FBS-83118 OC-2)
325 419 $probe = self::probe_key( $opts );
@@ -327,11 +421,11 @@
327 421 $got = $rc->get( $probe );
328 422 $rc->del( $probe );
329 423 $rc->close();
330 424 if ( ! $set || '1' !== (string) $got ) {
331 - return self::test_result( false, $backend, self::write_denied_message( $opts, $host, $port ), $start );
425 + return self::test_result( false, $backend, self::write_denied_message( $opts, $where ), $start );
332 426 }
333 - return self::test_result( true, $backend, self::with_prefix_advisory( "Connected to Redis at {$host}:{$port} (built-in client).", $opts ), $start );
427 + return self::test_result( true, $backend, self::with_prefix_advisory( "Connected to Redis at {$where} (built-in client).", $opts ), $start );
334 428 } catch ( \Throwable $e ) {
335 429 return self::test_result( false, $backend, 'Connection error: ' . $e->getMessage() );
336 430 }
337 431 }
@@ -559,19 +653,18 @@
559 653 * Message for a connect-OK-but-write-denied result. Points ACL/namespaced
560 654 * hosts at the fix (match the key prefix to the host's Redis Object Cache
561 655 * Key), which is exactly the xCloud failure mode. (FBS-83118 OC-2)
562 656 *
563 - * @param array $opts Settings array.
564 - * @param string $host Redis host.
565 - * @param int $port Redis port.
657 + * @param array $opts Settings array.
658 + * @param string $where host:port, or the socket path.
566 659 * @return string
567 660 */
568 - private static function write_denied_message( array $opts, string $host, int $port ): string {
661 + private static function write_denied_message( array $opts, string $where ): string {
569 662 $has_prefix = '' !== self::str( $opts, 'key_prefix', '' );
570 663 $hint = $has_prefix
571 664 ? 'The Redis user may lack write permission for this key prefix (NOPERM).'
572 665 : 'On ACL/namespaced Redis (e.g. xCloud), set Cache key prefix to the host\'s "Redis Object Cache Key" so writes land in the permitted namespace.';
573 - return "Connected to Redis at {$host}:{$port}, but the cache could not store data. {$hint}";
666 + return "Connected to Redis at {$where}, but the cache could not store data. {$hint}";
574 667 }
575 668
576 669 /**
577 670 * Full plug-and-play enable: test → write wp-config constants → install
@@ -580,9 +673,56 @@
580 673 *
581 674 * @param array $opts Settings array.
582 675 * @return array{ok:bool,message:string,steps:array<string,bool>,test:array,detect:array}
583 676 */
584 - public static function enable( array $opts ): array {
677 + public static function enable( array $opts, array $args = array() ): array {
678 + /*
679 + * Another plugin's object-cache.php is a switch, not an install: its
680 + * owner has to be out of the picture first or it puts its file back
681 + * (W3TC on the next admin request, LiteSpeed on its next save). The
682 + * panel always refused here while REST, CLI and MCP overwrote the
683 + * file, so every entry point now refuses unless the caller asked for
684 + * the switch. (#686)
685 + */
686 + if ( self::foreign_dropin_present() ) {
687 + if ( empty( $args['takeover'] ) ) {
688 + $owner = Object_Cache_Takeover::owner();
689 + return array(
690 + 'ok' => false,
691 + 'needs_takeover' => true,
692 + 'message' => Object_Cache_Takeover::refusal_message( $owner ),
693 + 'steps' => array(
694 + 'connection' => false,
695 + 'wp_config' => false,
696 + 'drop_in' => false,
697 + 'verified' => false,
698 + ),
699 + 'owner' => $owner,
700 + 'detect' => self::detect( true ),
701 + );
702 + }
703 + return Object_Cache_Takeover::run( $opts );
704 + }
705 +
706 + // Enabling from scratch starts a new session, so an earlier switch's
707 + // record no longer describes anything to put back. Re-running enable
708 + // over our own drop-in (a re-sync, an import) keeps it.
709 + if ( ! self::is_our_dropin_present() ) {
710 + Object_Cache_Takeover::forget();
711 + }
712 + return self::install( $opts );
713 + }
714 +
715 + /**
716 + * Test, write the wp-config block and install our drop-in. The shared
717 + * second half of enable() and of a switch from another plugin; neither
718 + * calls it while a foreign drop-in is in place.
719 + *
720 + * @param array $opts Settings array.
721 + * @param array|null $test A connection test already run moments ago.
722 + * @return array{ok:bool,message:string,steps:array<string,bool>,test:array,detect:array}
723 + */
724 + public static function install( array $opts, ?array $test = null ): array {
585 725 $steps = array(
586 726 'connection' => false,
587 727 'wp_config' => false,
588 728 'drop_in' => false,
@@ -589,9 +729,9 @@
589 729 'verified' => false,
590 730 );
591 731
592 732 // 1. Don't write anything until the backend actually answers.
593 - $test = self::test_connection( $opts );
733 + $test = $test ?? self::test_connection( $opts );
594 734 if ( ! $test['ok'] ) {
595 735 return array(
596 736 'ok' => false,
597 737 'message' => 'Could not enable: ' . $test['message'],
@@ -596,13 +736,21 @@
596 736 'ok' => false,
597 737 'message' => 'Could not enable: ' . $test['message'],
598 738 'steps' => $steps,
599 739 'test' => $test,
600 - 'detect' => self::detect(),
740 + 'detect' => self::detect( true ),
601 741 );
602 742 }
603 743 $steps['connection'] = true;
604 744
745 + // Values from an earlier xSpeed session are still in Redis (keys
746 + // have no TTL, `alloptions` included), and the drop-in would read
747 + // them back as current. Only when ours is not already the live cache:
748 + // re-running enable over a working install must not cool it.
749 + if ( ! self::our_dropin_is_live() ) {
750 + self::purge_namespace( $opts );
751 + }
752 +
605 753 // 2. Write the XSPEED_OC_* constants into wp-config.php.
606 754 $steps['wp_config'] = self::write_wp_config( $opts );
607 755
608 756 // 3. Install our drop-in.
@@ -633,9 +781,9 @@
633 781 * Full reverse of enable(): remove drop-in + strip our wp-config block.
634 782 *
635 783 * @return array{ok:bool,message:string,steps:array<string,bool>,detect:array}
636 784 */
637 - public static function disable(): array {
785 + public static function disable( array $args = array() ): array {
638 786 // A drop-in owned by another plugin is left in place by
639 787 // remove_dropin(), which then reports success because nothing of ours
640 788 // is there to remove. Reporting "disabled" for that is a lie: the site
641 789 // still has someone else's object cache running. Say so instead.
@@ -647,12 +795,26 @@
647 795 'steps' => array(
648 796 'drop_in' => false,
649 797 'wp_config' => false,
650 798 ),
651 - 'detect' => self::detect(),
799 + 'detect' => self::detect( true ),
652 800 );
653 801 }
654 802
803 + // Empty our namespace before the drop-in goes, so a later enable
804 + // never reads this session's values back. Our cache stays loaded
805 + // until this request ends, so whatever it writes after this point
806 + // (a restore's own option writes) is flushed again at shutdown.
807 + // (#686)
808 + if ( self::is_our_dropin_present() ) {
809 + if ( self::our_dropin_is_live() ) {
810 + self::flush();
811 + add_action( 'shutdown', array( __CLASS__, 'flush' ), PHP_INT_MAX );
812 + } elseif ( class_exists( __NAMESPACE__ . '\\Settings_Manager' ) ) {
813 + self::purge_namespace( (array) Settings_Manager::get( 'object-cache' ) );
814 + }
815 + }
816 +
655 817 $dropin_removed = self::remove_dropin();
656 818 $config_removed = self::remove_wp_config();
657 819
658 820 /*
@@ -663,18 +825,38 @@
663 825 * from a file nothing in this path had touched.
664 826 */
665 827 self::delete_sidecar();
666 828
829 + $message = $dropin_removed
830 + ? 'Object cache disabled. Drop-in removed and wp-config.php cleaned.'
831 + : 'Could not remove the drop-in — wp-content may not be writable.';
832 +
833 + // Put back the plugin xSpeed switched from, when asked. Without the
834 + // ask, the record describes nothing any more.
835 + $restored = null;
836 + if ( $dropin_removed && ! empty( $args['restore'] ) ) {
837 + $restored = Object_Cache_Takeover::restore();
838 + $message .= ' ' . $restored['message'];
839 + } elseif ( $dropin_removed ) {
840 + // Not put back, but its namespace is cleared all the same, so a
841 + // later manual re-enable does not start from the switch's
842 + // snapshot.
843 + Object_Cache_Takeover::discard();
844 + }
845 +
667 846 return array(
668 - 'ok' => $dropin_removed,
669 - 'message' => $dropin_removed
670 - ? 'Object cache disabled. Drop-in removed and wp-config.php cleaned.'
671 - : 'Could not remove the drop-in — wp-content may not be writable.',
672 - 'steps' => array(
847 + // Whether xSpeed's object cache is off. A restore that could not
848 + // complete is reported beside it, in `restored` and the message:
849 + // the disable itself still happened. (#687)
850 + 'ok' => $dropin_removed,
851 + 'restored' => null === $restored ? null : $restored['ok'],
852 + 'message' => $message,
853 + 'steps' => array(
673 854 'drop_in' => $dropin_removed,
674 855 'wp_config' => $config_removed,
856 + 'restored' => null !== $restored && $restored['ok'],
675 857 ),
676 - 'detect' => self::detect(),
858 + 'detect' => self::detect( true ),
677 859 );
678 860 }
679 861
680 862 /**
@@ -706,9 +888,11 @@
706 888 if ( $is_xspeed ) {
707 889 if ( $existing === $source_contents ) {
708 890 return true;
709 891 }
710 - return (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
892 + $written = (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
893 + self::invalidate_compiled( $target );
894 + return $written;
711 895 }
712 896
713 897 // Foreign drop-in — back it up before overwriting.
714 898 $upload = wp_upload_dir( null, false );
@@ -723,9 +907,11 @@
723 907 $fs->delete( $target );
724 908 }
725 909 }
726 910
727 - return (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
911 + $written = (bool) $fs->put_contents( $target, $source_contents, FS_CHMOD_FILE );
912 + self::invalidate_compiled( $target );
913 + return $written;
728 914 }
729 915
730 916 /**
731 917 * Remove our drop-in (only if it's ours). Returns true when no xSpeed
@@ -742,8 +928,9 @@
742 928 }
743 929 $contents = $fs->get_contents( $target );
744 930 if ( is_string( $contents ) && false !== strpos( $contents, self::DROPIN_TAG ) ) {
745 931 wp_delete_file( $target );
932 + self::invalidate_compiled( $target );
746 933 return ! file_exists( $target );
747 934 }
748 935 // Not ours — leave it, but report success (nothing of ours to remove).
749 936 return true;
@@ -1076,8 +1263,9 @@
1076 1263 }
1077 1264
1078 1265 $written = (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1079 1266 if ( $written ) {
1267 + self::invalidate_compiled( $wp_config );
1080 1268 // The block just changed; a memoized scan from earlier in this
1081 1269 // request would still name the previous set. (#398)
1082 1270 self::forget_our_constants();
1083 1271
@@ -1111,8 +1299,9 @@
1111 1299 $pattern = '/' . preg_quote( self::CONFIG_BEGIN, '/' ) . '.*?' . preg_quote( self::CONFIG_END, '/' ) . "\s*/s";
1112 1300 $config = preg_replace( $pattern, '', $config );
1113 1301 $removed = (bool) $fs->put_contents( $wp_config, $config, FS_CHMOD_FILE );
1114 1302 if ( $removed ) {
1303 + self::invalidate_compiled( $wp_config );
1115 1304 // A scan from earlier in this request would still name the
1116 1305 // constants we just deleted, so origins() would report a field as
1117 1306 // ours -- editable -- when a host define is now the only source
1118 1307 // and the field should read as pinned.
@@ -1281,8 +1470,37 @@
1281 1470 * (no credentials in a non-interactive context) and fatal in
1282 1471 * ftp_fget(). We only need 'direct' — these writes target wp-config.php /
1283 1472 * wp-content, both owned by the PHP user on a normal install.
1284 1473 */
1474 + /**
1475 + * Drop a file's compiled copy from OPcache after we rewrite or remove it.
1476 + *
1477 + * wp-config.php and object-cache.php are both compiled once and reused.
1478 + * With the default revalidate_freq of 2 seconds, and indefinitely where a
1479 + * host turns validate_timestamps off, requests right after a write still
1480 + * run the old code: the panel read "Off" and "Backend: unknown" just
1481 + * after a switch because the new block's constants were not defined yet,
1482 + * and the old drop-in was still the live cache. (#687)
1483 + *
1484 + * @param string $path Absolute file path.
1485 + */
1486 + public static function invalidate_compiled( string $path ): void {
1487 + if ( function_exists( 'wp_opcache_invalidate' ) ) {
1488 + wp_opcache_invalidate( $path, true );
1489 + } elseif ( function_exists( 'opcache_invalidate' ) ) {
1490 + @opcache_invalidate( $path, true ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- opcache may be disabled or restricted; nothing to do either way.
1491 + }
1492 + }
1493 +
1494 + /**
1495 + * The direct filesystem, for Object_Cache_Takeover's copies and deletes.
1496 + *
1497 + * @return \WP_Filesystem_Base|null
1498 + */
1499 + public static function filesystem() {
1500 + return self::fs();
1501 + }
1502 +
1285 1503 private static function fs() {
1286 1504 global $wp_filesystem;
1287 1505 if ( ! function_exists( 'WP_Filesystem' ) ) {
1288 1506 require_once ABSPATH . 'wp-admin/includes/file.php';