Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
File
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 19
3906
0.00% covered (danger)
0.00%
0 / 1
 should_enable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 exclusionChange
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 on_rewrite
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 maybeAddRules
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 addRules
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 removeRules
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 maybeGeneratePageCache
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 write
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 isCacheable
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
420
 shouldCache
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
56
 exclusions
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getExpirationTimeframe
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 purge_all
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 purge_url
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getStoragePathForRequest
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getStorageFileForRequest
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 on_activation
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 on_deactivation
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace NewfoldLabs\WP\Module\Performance\Cache\Types;
4
5use NewfoldLabs\WP\Module\Performance\Cache\Purgeable;
6use NewfoldLabs\WP\Module\Performance\OptionListener;
7use NewfoldLabs\WP\ModuleLoader\Container;
8use NewfoldLabs\WP\Module\Performance\Cache\CacheExclusion;
9use NewfoldLabs\WP\Module\Performance\Cache\CacheManager;
10use NewfoldLabs\WP\Module\Performance\Cache\Types\Fragments\FileCacheFragment;
11use NewfoldLabs\WP\Module\Htaccess\Api as HtaccessApi;
12use wpscholar\Url;
13
14use function NewfoldLabs\WP\Module\Performance\get_cache_level;
15use function NewfoldLabs\WP\Module\Performance\remove_directory;
16use function NewfoldLabs\WP\Module\Performance\should_cache_pages;
17use function NewfoldLabs\WP\ModuleLoader\container as getContainer;
18
19/**
20 * File cache type.
21 */
22class File extends CacheBase implements Purgeable {
23    /**
24     * The directory where cached files live.
25     *
26     * @var string
27     */
28    const CACHE_DIR = WP_CONTENT_DIR . '/newfold-page-cache/';
29
30    /**
31     * Human-friendly marker label used in BEGIN/END comments.
32     *
33     * @var string
34     */
35    const MARKER = 'Newfold File Cache';
36
37    /**
38     * Registry identifier for this fragment.
39     * Must be globally unique across fragments.
40     *
41     * @var string
42     */
43    const FRAGMENT_ID = 'nfd.cache.file';
44
45    /**
46     * Whether or not the code for this cache type should be loaded.
47     *
48     * @param Container $container Dependency injection container.
49     * @return bool
50     */
51    public static function should_enable( Container $container ) {
52        return (bool) $container->has( 'isApache' ) && $container->get( 'isApache' );
53    }
54
55    /**
56     * Constructor.
57     */
58    public function __construct() {
59        new OptionListener( CacheManager::OPTION_CACHE_LEVEL, array( __CLASS__, 'maybeAddRules' ) );
60        new OptionListener( CacheExclusion::OPTION_CACHE_EXCLUSION, array( __CLASS__, 'exclusionChange' ) );
61
62        add_action( 'init', array( $this, 'maybeGeneratePageCache' ) );
63        add_action( 'newfold_update_htaccess', array( $this, 'on_rewrite' ) );
64    }
65
66    /**
67     * Manage on exclusion option change.
68     *
69     * @return void
70     */
71    public static function exclusionChange() {
72        self::maybeAddRules( get_cache_level() );
73    }
74
75    /**
76     * When updating mod rewrite rules, also update our rewrites as appropriate.
77     *
78     * @return void
79     */
80    public function on_rewrite() {
81        self::maybeAddRules( get_cache_level() );
82    }
83
84    /**
85     * Determine whether to add or remove rules based on caching level and brand.
86     *
87     * @param int $cache_level The caching level.
88     * @return void
89     */
90    public static function maybeAddRules( $cache_level ) {
91        $brand = getContainer()->plugin()->brand;
92
93        if ( absint( $cache_level ) > 1 && 'bluehost' !== $brand && 'hostgator' !== $brand ) {
94            self::addRules();
95        } else {
96            self::removeRules();
97        }
98    }
99
100    /**
101     * Register (or replace) our fragment with current settings.
102     *
103     * @return void
104     */
105    public static function addRules() {
106        // Compute base path and relative cache directory path for rewrite rules.
107        $base_path      = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH );
108        $rel_cache_path = str_replace( trailingslashit( get_home_path() ), '/', trailingslashit( self::CACHE_DIR ) );
109
110        // Build optional exclusion pattern (pipe-separated).
111        $exclusion_pattern = '';
112        $cache_exclusion   = get_option( CacheExclusion::OPTION_CACHE_EXCLUSION, '' );
113
114        if ( is_string( $cache_exclusion ) && '' !== $cache_exclusion ) {
115            $parts             = array_map( 'trim', explode( ',', sanitize_text_field( $cache_exclusion ) ) );
116            $exclusion_pattern = implode( '|', array_filter( $parts ) );
117        }
118
119        HtaccessApi::register(
120            new FileCacheFragment(
121                self::FRAGMENT_ID,
122                self::MARKER,
123                $base_path,
124                $rel_cache_path,
125                $exclusion_pattern
126            ),
127            true // queue apply
128        );
129    }
130
131    /**
132     * Unregister our fragment.
133     *
134     * @return void
135     */
136    public static function removeRules() {
137        HtaccessApi::unregister( self::FRAGMENT_ID );
138    }
139
140    /**
141     * Initiate the generation of a page cache for a given request, if necessary.
142     *
143     * @return void
144     */
145    public function maybeGeneratePageCache() {
146        if ( $this->isCacheable() ) {
147            if ( $this->shouldCache() ) {
148                ob_start( array( $this, 'write' ) );
149            }
150        } else {
151            nocache_headers();
152        }
153    }
154
155    /**
156     * Write page content to cache.
157     *
158     * @param  string $content  Page content to be cached.
159     * @return string
160     */
161    public function write( $content ) {
162        if ( ! empty( $content ) ) {
163
164            $path = $this->getStoragePathForRequest();
165            $file = $this->getStorageFileForRequest();
166
167            if ( false !== strpos( $content, '</html>' ) ) {
168                $content .= "\n<!--Generated by Newfold Page Cache-->";
169            }
170
171            global $wp_filesystem;
172
173            if ( ! function_exists( 'WP_Filesystem' ) ) {
174                require_once ABSPATH . 'wp-admin/includes/file.php';
175            }
176
177            WP_Filesystem();
178
179            if ( ! $wp_filesystem->is_dir( $path ) ) {
180                $wp_filesystem->mkdir( $path, 0755 );
181            }
182
183            $wp_filesystem->put_contents( $file, $content, FS_CHMOD_FILE );
184        }
185
186        return $content;
187    }
188
189    /**
190     * Check if the current request is cacheable.
191     *
192     * @return bool
193     */
194    public function isCacheable() {
195        // The request URI should never be empty â€“ even for the homepage it should be '/'
196        if ( empty( $_SERVER['REQUEST_URI'] ) ) {
197            return false;
198        }
199
200        // Don't cache if pretty permalinks are disabled
201        if ( false === get_option( 'permalink_structure' ) ) {
202            return false;
203        }
204
205        // Only cache front-end pages
206        if ( is_admin() || wp_doing_ajax() || wp_doing_cron() ) {
207            return false;
208        }
209
210        // Don't cache REST API requests
211        if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
212            return false;
213        }
214
215        // Never cache requests made via WP-CLI
216        if ( defined( 'WP_CLI' ) && WP_CLI ) {
217            return false;
218        }
219
220        // Don't cache if there are URL parameters present
221        if ( isset( $_GET ) && ! empty( $_GET ) ) { // phpcs:ignore WordPress.Security.NonceVerification
222            return false;
223        }
224
225        // Don't cache if handling a form submission
226        if ( isset( $_POST ) && ! empty( $_POST ) ) { // phpcs:ignore WordPress.Security.NonceVerification
227            return false;
228        }
229
230        // Don't cache if a user is logged in.
231        if ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() ) {
232            return false;
233        }
234
235        global $wp_query;
236        if ( isset( $wp_query ) ) {
237
238            // Don't cache 404 pages or RSS feeds
239            if ( is_404() || is_feed() ) {
240                return false;
241            }
242        }
243
244        // Don't cache private pages
245        if ( 'private' === get_post_status() ) {
246            return false;
247        }
248
249        return true;
250    }
251
252    /**
253     * Check if we should cache the current request.
254     *
255     * @return bool
256     */
257    public function shouldCache() {
258        // If page caching is disabled, then don't cache
259        if ( ! should_cache_pages() ) {
260            return false;
261        }
262
263        // Check cache exclusion (application-level).
264        $cache_exclusion_parameters = $this->exclusions();
265        if ( ! empty( $cache_exclusion_parameters ) ) {
266            foreach ( $cache_exclusion_parameters as $param ) {
267                if ( stripos( $_SERVER['REQUEST_URI'], $param ) !== false ) {
268                    return false;
269                }
270            }
271        }
272
273        // Don't cache if a file exists and hasn't expired.
274        $file = $this->getStorageFileForRequest();
275        if ( file_exists( $file ) && filemtime( $file ) + $this->getExpirationTimeframe() > time() ) {
276            return false;
277        }
278
279        return true;
280    }
281
282    /**
283     * Get an array of strings that should not be present in the URL for a request to be cached.
284     *
285     * @return array
286     */
287    protected function exclusions() {
288        $default                = array( 'cart', 'checkout', 'wp-admin', '@', '%', ':', ';', '&', '=', '.', rest_get_url_prefix() );
289        $cache_exclusion_option = array_map( 'trim', explode( ',', (string) get_option( CacheExclusion::OPTION_CACHE_EXCLUSION ) ) );
290        return array_merge( $default, array_filter( $cache_exclusion_option ) );
291    }
292
293    /**
294     * Get expiration duration.
295     *
296     * @return int
297     */
298    protected function getExpirationTimeframe() {
299        switch ( get_cache_level() ) {
300            case 2:
301                return 2 * HOUR_IN_SECONDS;
302            case 3:
303                return 8 * HOUR_IN_SECONDS;
304            default:
305                return 0;
306        }
307    }
308
309    /**
310     * Purge everything from the cache.
311     *
312     * @return void
313     */
314    public function purge_all() {
315        remove_directory( self::CACHE_DIR );
316    }
317
318    /**
319     * Purge a specific URL from the cache.
320     *
321     * @param string $url the url to purge.
322     * @return void
323     */
324    public function purge_url( $url ) {
325        $path = $this->getStoragePathForRequest();
326
327        if ( trailingslashit( self::CACHE_DIR ) === $path ) {
328            if ( file_exists( self::CACHE_DIR . '/_index.html' ) ) {
329                wp_delete_file( self::CACHE_DIR . '/_index.html' );
330            }
331
332            return;
333        }
334
335        remove_directory( $this->getStoragePathForRequest() );
336    }
337
338    /**
339     * Get storage path for a given request.
340     *
341     * @return string
342     */
343    protected function getStoragePathForRequest() {
344        static $path;
345
346        if ( ! isset( $path ) ) {
347            $url       = new Url();
348            $base_path = (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH );
349            $path      = trailingslashit( self::CACHE_DIR . str_replace( $base_path, '', esc_url( $url->path ) ) );
350        }
351
352        return $path;
353    }
354
355    /**
356     * Get storage file for a given request.
357     *
358     * @return string
359     */
360    protected function getStorageFileForRequest() {
361        return $this->getStoragePathForRequest() . '_index.html';
362    }
363
364    /**
365     * Handle activation logic.
366     *
367     * @return void
368     */
369    public static function on_activation() {
370        self::maybeAddRules( get_cache_level() );
371    }
372
373    /**
374     * Handle deactivation logic.
375     *
376     * @return void
377     */
378    public static function on_deactivation() {
379        // Remove file cache rules from .htaccess via fragment unregister.
380        self::removeRules();
381
382        // Remove all statically cached files.
383        remove_directory( self::CACHE_DIR );
384    }
385}