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