Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 149
0.00% covered (danger)
0.00%
0 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ObjectCache
0.00% covered (danger)
0.00%
0 / 149
0.00% covered (danger)
0.00%
0 / 14
3906
0.00% covered (danger)
0.00%
0 / 1
 is_available
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 get_drop_in_path
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_our_drop_in
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 get_state
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 enable
0.00% covered (danger)
0.00%
0 / 56
0.00% covered (danger)
0.00%
0 / 1
156
 disable
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
90
 on_deactivation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 is_preference_enabled
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 maybe_restore_dropin
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 maybe_restore_on_activation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 maybe_remove_dropin_if_unavailable
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
72
 flush_object_cache
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 clear_options_object_cache
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 flush_and_clear_on_shutdown
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3namespace NewfoldLabs\WP\Module\Performance\Cache\Types;
4
5/**
6 * Object cache (Redis drop-in) management for the performance module.
7 *
8 * Not a page-cache type; does not go in CacheManager::classMap().
9 * Handles detection, enable/disable, and flush of the Redis object-cache drop-in.
10 */
11class ObjectCache {
12
13    /**
14     * URL to download the object-cache drop-in from.
15     *
16     * @var string
17     */
18    const DROPIN_URL = 'https://raw.githubusercontent.com/newfold-labs/wp-drop-in-redis-object-cache/prod/object-cache.php';
19
20    /**
21     * Identifier string in our drop-in file header (Plugin Name). Must match the
22     * drop-in's "Plugin Name:" value so is_our_drop_in() and download validation work.
23     *
24     * @var string
25     */
26    const DROPIN_HEADER_IDENTIFIER = 'Redis Object Cache Drop-In';
27
28    /**
29     * Bytes to read from the drop-in file when checking if it is ours.
30     *
31     * @var int
32     */
33    const HEADER_READ_BYTES = 2048;
34
35    /**
36     * Minimum constants required for a Redis connection (at least one must be defined).
37     *
38     * @var string[]
39     */
40    const REDIS_CONNECTION_CONSTANTS = array(
41        'WP_REDIS_HOST',
42        'WP_REDIS_SERVERS',
43        'WP_REDIS_CLUSTER',
44        'WP_REDIS_SHARDS',
45        'WP_REDIS_SENTINEL',
46    );
47
48    /**
49     * Option name for persisting user preference (enabled = true, disabled = false).
50     * Used on plugin re-activation to restore the drop-in if it was enabled before deactivation.
51     * When the option is missing (first activation), we enable object cache by default when Redis is available.
52     *
53     * @var string
54     */
55    const OPTION_ENABLED_PREFERENCE = 'newfold_object_cache_enabled_preference';
56
57    /**
58     * Whether the object cache feature is available (Redis connection constant is defined).
59     *
60     * @return bool
61     */
62    public static function is_available() {
63        foreach ( self::REDIS_CONNECTION_CONSTANTS as $constant ) {
64            if ( defined( $constant ) ) {
65                return true;
66            }
67        }
68        return false;
69    }
70
71    /**
72     * Path to the object-cache drop-in file.
73     *
74     * @return string
75     */
76    public static function get_drop_in_path() {
77        return WP_CONTENT_DIR . '/object-cache.php';
78    }
79
80    /**
81     * Check if the file at the given path is our drop-in (by header).
82     *
83     * @param string $path Full path to object-cache.php.
84     * @return bool
85     */
86    public static function is_our_drop_in( $path ) {
87        if ( ! is_readable( $path ) ) {
88            return false;
89        }
90        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file header read, not remote.
91        $content = @file_get_contents( $path, false, null, 0, self::HEADER_READ_BYTES );
92        if ( false === $content || '' === $content ) {
93            return false;
94        }
95        return strpos( $content, self::DROPIN_HEADER_IDENTIFIER ) !== false;
96    }
97
98    /**
99     * Get current object cache state for the UI and API.
100     *
101     * @return array{available: bool, enabled: bool, overwritten: bool, ours: bool}
102     */
103    public static function get_state() {
104        $available = self::is_available();
105        $path      = self::get_drop_in_path();
106        $exists    = file_exists( $path );
107        $ours      = $exists && self::is_our_drop_in( $path );
108
109        return array(
110            'available'   => $available,
111            'enabled'     => $available && $ours,
112            'overwritten' => $available && $exists && ! $ours,
113            'ours'        => $ours,
114        );
115    }
116
117    /**
118     * Enable object cache by downloading and writing the drop-in.
119     * If our drop-in is already present, we do not download again; we just ensure the preference is set.
120     *
121     * @return array{success: bool, message?: string}
122     */
123    public static function enable() {
124        if ( ! self::is_available() ) {
125            return array(
126                'success' => false,
127                'message' => __( 'Object cache is not available. Configure Redis in wp-config.php first.', 'wp-module-performance' ),
128            );
129        }
130        $state = self::get_state();
131        if ( $state['overwritten'] ) {
132            return array(
133                'success' => false,
134                'message' => __( 'Another object cache drop-in is active. Disable it in the other plugin first.', 'wp-module-performance' ),
135            );
136        }
137        if ( $state['ours'] ) {
138            update_option( self::OPTION_ENABLED_PREFERENCE, true );
139            return array( 'success' => true );
140        }
141
142        $path     = self::get_drop_in_path();
143        $response = wp_remote_get(
144            self::DROPIN_URL,
145            array(
146                'timeout'   => 15,
147                'sslverify' => true,
148            )
149        );
150
151        if ( is_wp_error( $response ) ) {
152            return array(
153                'success' => false,
154                'message' => $response->get_error_message(),
155            );
156        }
157
158        $code = wp_remote_retrieve_response_code( $response );
159        if ( 200 !== $code ) {
160            return array(
161                'success' => false,
162                'message' => sprintf(
163                    /* translators: %d: HTTP status code */
164                    __( 'Failed to download object cache (HTTP %d).', 'wp-module-performance' ),
165                    $code
166                ),
167            );
168        }
169
170        $body = wp_remote_retrieve_body( $response );
171        if ( empty( $body ) || strpos( $body, self::DROPIN_HEADER_IDENTIFIER ) === false ) {
172            return array(
173                'success' => false,
174                'message' => __( 'Downloaded content is not valid. Please try again.', 'wp-module-performance' ),
175            );
176        }
177
178        if ( ! function_exists( 'WP_Filesystem' ) ) {
179            require_once ABSPATH . 'wp-admin/includes/file.php';
180        }
181        WP_Filesystem();
182        global $wp_filesystem;
183        if ( $wp_filesystem && $wp_filesystem->put_contents( $path, $body, FS_CHMOD_FILE ) ) {
184            update_option( self::OPTION_ENABLED_PREFERENCE, true );
185            return array( 'success' => true );
186        }
187
188        // Fallback to file_put_contents if WP_Filesystem failed (e.g. direct method not available).
189        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Intentional fallback after WP_Filesystem.
190        if ( false !== @file_put_contents( $path, $body ) ) {
191            update_option( self::OPTION_ENABLED_PREFERENCE, true );
192            return array( 'success' => true );
193        }
194
195        return array(
196            'success' => false,
197            'message' => __( 'Could not write object-cache.php. Check file permissions.', 'wp-module-performance' ),
198        );
199    }
200
201    /**
202     * Disable object cache by removing the drop-in (only if it is ours).
203     *
204     * @param bool $clear_preference If true (default), set the enabled preference to false. Pass false when called from deactivation so the last user state is preserved.
205     * @return array{success: bool, message?: string}
206     */
207    public static function disable( $clear_preference = true ) {
208        $path = self::get_drop_in_path();
209        if ( ! file_exists( $path ) ) {
210            return array(
211                'success' => false,
212                'message' => __( 'Object cache is not enabled.', 'wp-module-performance' ),
213            );
214        }
215        if ( ! self::is_our_drop_in( $path ) ) {
216            return array(
217                'success' => false,
218                'message' => __( 'Another object cache drop-in is active. Disable it in the other plugin first.', 'wp-module-performance' ),
219            );
220        }
221
222        // Flush Redis and clear options cache while our drop-in is still active, then remove the file.
223        self::flush_object_cache();
224        self::clear_options_object_cache();
225
226        if ( ! function_exists( 'WP_Filesystem' ) ) {
227            require_once ABSPATH . 'wp-admin/includes/file.php';
228        }
229        WP_Filesystem();
230        global $wp_filesystem;
231        if ( $wp_filesystem && $wp_filesystem->delete( $path ) ) {
232            if ( $clear_preference ) {
233                // Store false (do not delete option) so maybe_restore_on_activation knows user turned off.
234                update_option( self::OPTION_ENABLED_PREFERENCE, false );
235            }
236            // Only on this request: flush Redis at shutdown so it stays empty (no repopulation from get_option).
237            add_action( 'shutdown', array( self::class, 'flush_and_clear_on_shutdown' ), PHP_INT_MAX );
238            return array( 'success' => true );
239        }
240
241        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- Intentional fallback after WP_Filesystem.
242        if ( @unlink( $path ) ) {
243            if ( $clear_preference ) {
244                // Store false (do not delete option) so maybe_restore_on_activation knows user turned off.
245                update_option( self::OPTION_ENABLED_PREFERENCE, false );
246            }
247            // Only on this request: flush Redis at shutdown so it stays empty (no repopulation from get_option).
248            add_action( 'shutdown', array( self::class, 'flush_and_clear_on_shutdown' ), PHP_INT_MAX );
249            return array( 'success' => true );
250        }
251
252        return array(
253            'success' => false,
254            'message' => __( 'Could not remove object-cache.php. Check file permissions.', 'wp-module-performance' ),
255        );
256    }
257
258    /**
259     * Remove our object-cache drop-in on plugin/performance deactivation.
260     * Only deletes the file if it is our drop-in. Does not change the enabled preference,
261     * so whatever state the user had (on or off) is preserved for re-activation.
262     *
263     * @return void
264     */
265    public static function on_deactivation() {
266        self::disable( false );
267    }
268
269    /**
270     * Whether the stored preference means "user wants object cache on".
271     * WordPress may store true as 1 or '1' in the database.
272     *
273     * @return bool
274     */
275    public static function is_preference_enabled() {
276        $preference = get_option( self::OPTION_ENABLED_PREFERENCE, null );
277        return in_array( $preference, array( null, true, 1, '1' ), true );
278    }
279
280    /**
281     * Restore the drop-in when Redis is available, user preference is "on", and the file is missing.
282     * Used on activation and when serving cache settings so the UI state matches the preference.
283     *
284     * @return void
285     */
286    public static function maybe_restore_dropin() {
287        if ( ! self::is_available() ) {
288            return;
289        }
290        if ( ! self::is_preference_enabled() ) {
291            return;
292        }
293        if ( self::get_state()['ours'] ) {
294            return;
295        }
296        self::enable();
297    }
298
299    /**
300     * On plugin/performance activation: if Redis is available and the drop-in file is missing, enable it.
301     *
302     * @return void
303     */
304    public static function maybe_restore_on_activation() {
305        self::maybe_restore_dropin();
306    }
307
308    /**
309     * If Redis config is no longer present (e.g. constants commented out in wp-config) but our drop-in
310     * is still in place, remove the drop-in so WordPress does not load a broken object cache.
311     * Also clears the enabled preference so state stays consistent.
312     *
313     * @return void
314     */
315    public static function maybe_remove_dropin_if_unavailable() {
316        if ( self::is_available() ) {
317            return;
318        }
319        $path = self::get_drop_in_path();
320        if ( ! file_exists( $path ) || ! self::is_our_drop_in( $path ) ) {
321            return;
322        }
323        if ( ! function_exists( 'WP_Filesystem' ) ) {
324            require_once ABSPATH . 'wp-admin/includes/file.php';
325        }
326        WP_Filesystem();
327        global $wp_filesystem;
328        if ( $wp_filesystem && $wp_filesystem->delete( $path ) ) {
329            update_option( self::OPTION_ENABLED_PREFERENCE, false );
330            return;
331        }
332        // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, WordPress.WP.AlternativeFunctions.unlink_unlink -- Intentional fallback after WP_Filesystem.
333        if ( @unlink( $path ) ) {
334            update_option( self::OPTION_ENABLED_PREFERENCE, false );
335        }
336    }
337
338    /**
339     * Flush the object cache (Redis). Only flushes when our drop-in is active.
340     *
341     * Used as part of "Clear cache" action; no separate endpoint.
342     *
343     * @return void
344     */
345    public static function flush_object_cache() {
346        $state = self::get_state();
347        if ( ! $state['enabled'] ) {
348            return;
349        }
350        if ( function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache() && function_exists( 'wp_cache_flush' ) ) {
351            wp_cache_flush();
352        }
353    }
354
355    /**
356     * Clear options-related keys from the object cache so the next request reads from DB.
357     * Call when turning off object cache (or on deactivation) to avoid stale active_plugins/alloptions.
358     *
359     * @return void
360     */
361    public static function clear_options_object_cache() {
362        if ( ! function_exists( 'wp_cache_delete' ) ) {
363            return;
364        }
365        wp_cache_delete( 'active_plugins', 'options' );
366        wp_cache_delete( 'alloptions', 'options' );
367        if ( function_exists( 'wp_cache_flush_group' ) ) {
368            wp_cache_flush_group( 'options' );
369        }
370        if ( function_exists( 'wp_cache_flush_runtime' ) ) {
371            wp_cache_flush_runtime();
372        }
373    }
374
375    /**
376     * Flush entire object cache and clear options keys. Only ever run via shutdown hook
377     * registered in disable() when we have just removed the drop-in (that one request only).
378     * Not run on normal requests—Redis cache is unaffected until the user turns object cache off.
379     *
380     * @return void
381     */
382    public static function flush_and_clear_on_shutdown() {
383        if ( function_exists( 'wp_using_ext_object_cache' ) && wp_using_ext_object_cache() && function_exists( 'wp_cache_flush' ) ) {
384            wp_cache_flush();
385        }
386        self::clear_options_object_cache();
387    }
388}