settings = \QWSpeedCache\Plugin::instance()->get_all_settings(); $this->cache_dir = QWSC_CACHE_DIR; if ( empty( $this->settings['cache_enabled'] ) ) { return; } if ( ! \QWSpeedCache\Plugin::should_optimize() ) { return; } add_action( 'init', array( $this, 'maybe_serve_cache' ), 1 ); add_action( 'template_redirect', array( $this, 'start_buffering' ), 0 ); add_action( 'qwsc_cache_purged_all', array( $this, 'cleanup_stale_temps' ) ); } /** Return a bounded, sanitized request URI for cache routing and storage. */ private static function request_uri() { $raw = isset( $_SERVER['REQUEST_URI'] ) && is_string( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '/'; $uri = sanitize_text_field( $raw ); return '/' === substr( $uri, 0, 1 ) && strlen( $uri ) <= 8192 ? $uri : '/'; } private function is_cacheable() { if ( empty( $this->settings['page_cache_enabled'] ) && empty( $this->settings['cache_enabled'] ) ) { Status::set( 'OFF', 'disabled' ); return false; } $uri = self::request_uri(); $password = false; if ( \QWSpeedCache\Cache\Status::query_ready() && function_exists( 'is_singular' ) && is_singular() ) { $post = get_post(); $password = ( $post && ! empty( $post->post_password ) ); } $decision = SafetyPolicy::request_cache_decision( array( 'method' => isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET', 'uri' => $uri, 'cookies' => isset( $_COOKIE ) && is_array( $_COOKIE ) ? $_COOKIE : array(), 'is_admin' => function_exists( 'is_admin' ) && is_admin(), 'is_logged_in' => function_exists( 'is_user_logged_in' ) && is_user_logged_in(), 'is_preview' => ( \QWSpeedCache\Cache\Status::query_ready() && function_exists( 'is_preview' ) && is_preview() ) || ( function_exists( 'is_customize_preview' ) && is_customize_preview() ), 'is_feed' => ( \QWSpeedCache\Cache\Status::query_ready() && function_exists( 'is_feed' ) && is_feed() ) || ( \QWSpeedCache\Cache\Status::query_ready() && function_exists( 'is_trackback' ) && is_trackback() ), 'doing_ajax' => defined( 'DOING_AJAX' ) && DOING_AJAX, 'rest_request' => defined( 'REST_REQUEST' ) && REST_REQUEST, 'donotcachepage' => defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE, 'has_post_password' => $password, 'is_multisite_signup' => function_exists( 'is_multisite' ) && is_multisite() && function_exists( 'is_signup_page' ) && is_signup_page(), ) ); if ( empty( $decision['allowed'] ) ) { Status::set( 'BYPASS', $decision['reason'] ?? 'policy' ); return false; } // Plain-permalink post requests cannot be resolved safely during init; // resolve them in WordPress instead of serving an early cached homepage. if ( isset( $_GET['p'] ) || isset( $_GET['page_id'] ) || isset( $_GET['attachment_id'] ) || isset( $_GET['name'] ) ) { Status::set( 'BYPASS', 'post_query' ); return false; } if ( ! empty( $_GET ) && ! $this->is_allowed_query() ) { Status::set( 'BYPASS', 'query' ); return false; } if ( $this->is_rejected_uri() ) { Status::set( 'BYPASS', 'uri' ); return false; } // Per-post and integration bypass; conditional tags are only valid after the main query. if ( \QWSpeedCache\Cache\Status::query_ready() && (bool) apply_filters( 'qwsc_cache_bypass', false ) ) { Status::set( 'BYPASS', 'filter' ); return false; } if ( $this->has_rejected_cookie() ) { Status::set( 'BYPASS', 'cookie' ); return false; } if ( $this->is_rejected_ua() ) { Status::set( 'BYPASS', 'ua' ); return false; } Status::set( 'MISS', 'miss' ); return true; } /** * True when the current query string still allows caching. * Reject-list query args force a miss; other args may be ignored for the key. */ private function is_allowed_query() { return self::query_allows_caching( $_GET, $this->settings ); } /** * Whether a query map is cacheable under settings (testable entry point). * * @param array $get Query map. * @param array $settings Plugin settings. * @return bool */ public static function query_allows_caching( array $get, array $settings ) { $reject = Exclusions::parse_lines( (string) ( $settings['cache_reject_qs'] ?? '' ) ); if ( Exclusions::query_is_rejected( $get, $reject ) ) { return false; } $ignore = array_unique( array_merge( Exclusions::default_ignore_qs(), Exclusions::premium_query_rules( $settings, 'cache_ignore_qs' ) ) ); $allowed_raw = Exclusions::premium_query_rules( $settings, 'cache_allowed_qs' ); $allowed = empty( $allowed_raw ) ? null : $allowed_raw; if ( Exclusions::query_has_unknown_allowed_params( $get, $ignore, $allowed ) ) { return false; } return true; } private function is_rejected_uri() { $uri = self::request_uri(); $rules = array_unique( array_merge( Exclusions::default_reject_uris(), Exclusions::parse_lines( (string) ( $this->settings['cache_reject_uri'] ?? '' ) ) ) ); if ( Exclusions::uri_is_rejected( $uri, $rules ) ) { return true; } // Posts marked "Never cache": exact path match, works before the main query. $path = Exclusions::normalize_path( Exclusions::path_only( $uri ) ); return in_array( $path, Exclusions::never_cache_paths(), true ); } private function has_rejected_cookie() { $rules = array_unique( array_merge( Exclusions::default_reject_cookies(), Exclusions::parse_lines( (string) ( $this->settings['cache_reject_cookies'] ?? '' ) ) ) ); $allow_empty = ! empty( $this->settings['woo_cache_empty_cart'] ); return Exclusions::cookies_are_rejected( $_COOKIE, $rules, $allow_empty ); } private function is_rejected_ua() { $rules = Exclusions::parse_lines( (string) ( $this->settings['cache_reject_ua'] ?? '' ) ); $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) && is_string( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; return Exclusions::ua_is_rejected( $ua, $rules ); } private function get_cache_key() { $path = self::request_uri(); $mobile = ( ! empty( $this->settings['cache_mobile'] ) && function_exists( 'wp_is_mobile' ) && wp_is_mobile() ); return self::make_cache_key( $_GET, $path, $this->settings, $mobile ); } /** * Build cache key (testable entry point). * * @param array $get Query. * @param string $path Request path or URI. * @param array $settings Settings. * @param bool $mobile Mobile variant. * @return string */ public static function make_cache_key( array $get, $path, array $settings, $mobile = false ) { $host = Exclusions::site_host(); $scheme = PathLayout::request_scheme(); $path = Exclusions::path_only( (string) $path ); $ignore = array_unique( array_merge( Exclusions::default_ignore_qs(), Exclusions::premium_query_rules( $settings, 'cache_ignore_qs' ) ) ); $allowed_raw = Exclusions::premium_query_rules( $settings, 'cache_allowed_qs' ); $allowed = empty( $allowed_raw ) ? null : $allowed_raw; $filtered = Exclusions::filter_query_for_cache_key( $get, $ignore, $allowed ); $qs = Exclusions::build_query_string( $filtered ); $uri = $path . ( '' !== $qs ? '?' . $qs : '' ); $device = SafetyPolicy::device_segment( $settings, (bool) $mobile ); $suffix = ''; if ( function_exists( 'apply_filters' ) ) { $suffix = (string) apply_filters( 'qwsc_cache_key_suffix', $suffix, $path, $settings ); } return md5( $scheme . '://' . $host . $uri . $device . $suffix ); } /** * Whether a cache file has exceeded lifespan (hours setting → seconds). * * @param string $file File path. * @param int $hours Lifespan hours. * @return bool */ public static function is_file_expired( $file, $hours ) { if ( ! is_string( $file ) || ! file_exists( $file ) ) { return true; } $hours = max( 0, (int) $hours ); if ( $hours <= 0 ) { return false; } $lifespan = $hours * ( defined( '\HOUR_IN_SECONDS' ) ? \HOUR_IN_SECONDS : 3600 ); return ( time() - (int) filemtime( $file ) ) > $lifespan; } private function get_paths() { $path = self::request_uri(); $path_only = Exclusions::path_only( $path ); $mobile = ( ! empty( $this->settings['cache_mobile'] ) && function_exists( 'wp_is_mobile' ) && wp_is_mobile() ); $device = SafetyPolicy::device_segment( $this->settings, (bool) $mobile ); $host = Exclusions::site_host(); $ignore = array_unique( array_merge( Exclusions::default_ignore_qs(), Exclusions::premium_query_rules( $this->settings, 'cache_ignore_qs' ) ) ); $allowed_raw = Exclusions::premium_query_rules( $this->settings, 'cache_allowed_qs' ); $allowed = empty( $allowed_raw ) ? null : $allowed_raw; $get = isset( $_GET ) && is_array( $_GET ) ? $_GET : array(); $filtered = Exclusions::filter_query_for_cache_key( $get, $ignore, $allowed ); $qs = Exclusions::build_query_string( $filtered ); $html = PathLayout::html_path( $this->cache_dir, $host, $device, $path_only, $qs, PathLayout::request_scheme() ); $dir = dirname( $html ) . '/'; if ( ! is_dir( $dir ) ) { wp_mkdir_p( $dir ); } return array( 'html' => $html, 'meta' => $dir . 'index.meta.json', 'lock' => $dir . 'index.lock', ); } public function maybe_serve_cache() { if ( ! $this->is_cacheable() ) { return; } $paths = $this->get_paths(); $lifespan = (int) \QWSpeedCache\Cache\Ttl::hours_for_request( $this->settings ) * \HOUR_IN_SECONDS; $html = PageStore::get( $this->settings, $paths['html'] ); if ( null === $html ) { // Stampede: claim regeneration lock so only one miss rebuilds. $this->claim_regen_lock( $paths['lock'] ); return; } $age = file_exists( $paths['html'] ) ? ( time() - (int) filemtime( $paths['html'] ) ) : 0; $expired = ( $lifespan > 0 && $age > $lifespan ); if ( $expired ) { // Pro may handle an expired entry via this extension point. // Free never serves expired content itself. $serve_stale = (bool) apply_filters( 'qwsc_cache_serve_stale', false, $age, $lifespan, $this->settings ); if ( $serve_stale ) { $won = $this->claim_regen_lock( $paths['lock'] ); if ( ! $won ) { $this->emit_cached_html( $html, $paths, $age, true ); exit; } Status::set( 'BYPASS', 'stale_regen' ); return; } PageStore::delete( $this->settings, $paths['html'] ); if ( file_exists( $paths['meta'] ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink unlink( $paths['meta'] ); } $this->claim_regen_lock( $paths['lock'] ); return; } $this->emit_cached_html( $html, $paths, $age, false ); exit; } /** * Non-blocking regeneration lock. Returns true if this request should rebuild. * * @param string $lock_path Lock file path. * @return bool */ private function claim_regen_lock( $lock_path ) { if ( ! isset( $this->settings['cache_stampede_protection'] ) ) { return true; } if ( isset( $this->settings['cache_stampede_protection'] ) && empty( $this->settings['cache_stampede_protection'] ) ) { return true; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors -- Native file descriptor is required for flock()-based cache stampede protection. $fp = @fopen( $lock_path, 'c+' ); if ( ! $fp ) { return true; } if ( ! flock( $fp, LOCK_EX | LOCK_NB ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing native flock() descriptor. fclose( $fp ); return false; } // Hold lock for this request lifecycle; unlock in save_cache / shutdown. $this->regen_lock_fp = $fp; $this->regen_lock_path = $lock_path; register_shutdown_function( function () { if ( ! empty( $this->regen_lock_fp ) ) { flock( $this->regen_lock_fp, LOCK_UN ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing native flock() descriptor held for cache stampede protection. fclose( $this->regen_lock_fp ); $this->regen_lock_fp = null; } } ); return true; } /** * @param string $html HTML. * @param array $paths Paths. * @param int $age Age seconds. * @param bool $stale Stale serve. */ private function emit_cached_html( $html, array $paths, $age, $stale ) { $ctype = 'text/html; charset=UTF-8'; if ( file_exists( $paths['meta'] ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading the local page-cache metadata sidecar; never a URL. $meta = json_decode( (string) file_get_contents( $paths['meta'] ), true ); if ( is_array( $meta ) && ! empty( $meta['content_type'] ) ) { $ctype = $meta['content_type']; } } header( 'Content-Type: ' . $ctype ); if ( $stale ) { Status::set( 'HIT', 'stale' ); header( 'X-QWSC-Cache: STALE' ); do_action( 'qwsc_cache_stat_hit' ); Headers::send_cache_hit_headers( $this->settings, $age, true ); } else { Status::set( 'HIT', 'hit' ); do_action( 'qwsc_cache_stat_hit' ); Headers::send_cache_hit_headers( $this->settings, $age, false ); } if ( isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) && 'HEAD' === strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) ) { return; } echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- cached full HTML document. } public function start_buffering() { if ( ! $this->is_cacheable() ) { return; } \QWSpeedCache\Support\OutputBuffer::start( array( $this, 'save_cache' ) ); } public function save_cache( $html ) { if ( empty( $html ) || strlen( $html ) < 200 ) { return $html; } if ( false === stripos( $html, '' ) ) { return $html; } // Plugins may define DONOTCACHEPAGE late (after is_cacheable ran). if ( defined( 'DONOTCACHEPAGE' ) && DONOTCACHEPAGE ) { Status::set( 'BYPASS', 'donotcachepage' ); return $html; } if ( function_exists( 'http_response_code' ) ) { $code = http_response_code(); if ( $code && (int) $code !== 200 ) { return $html; } } $trim = ltrim( $html ); if ( 0 === strpos( $trim, '{' ) || 0 === strpos( $trim, '[' ) || 0 === strpos( $trim, 'get_paths(); // Reuse regen lock from claim_regen_lock() — second flock() on a new FD fails on Linux. $holds = ( ! empty( $this->regen_lock_fp ) && is_resource( $this->regen_lock_fp ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors -- Native descriptor is required for non-blocking flock(). $lock_fp = $holds ? $this->regen_lock_fp : @fopen( $paths['lock'], 'c+' ); if ( ! $lock_fp || ( ! $holds && ! flock( $lock_fp, LOCK_EX | LOCK_NB ) ) ) { if ( $lock_fp && ! $holds ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing native flock() descriptor. fclose( $lock_fp ); } Headers::send_status_headers( 'MISS', 'miss' ); Status::set( 'MISS', 'miss' ); return $html; } $ttl = (int) \QWSpeedCache\Cache\Ttl::hours_for_request( $this->settings ) * ( defined( '\HOUR_IN_SECONDS' ) ? \HOUR_IN_SECONDS : 3600 ); if ( $ttl <= 0 ) { $ttl = 36000; } $written = PageStore::put( $this->settings, $paths['html'], $html, $ttl ); if ( $written ) { $req_url = home_url( self::request_uri() ); do_action( 'qwsc_cache_saved', $req_url, $html ); } if ( $written ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- cache meta sidecar. file_put_contents( $paths['meta'], wp_json_encode( array( 'content_type' => $ctype, 'created' => time(), 'status' => 200, 'backend' => PageStore::backend( $this->settings ), ) ), LOCK_EX ); } flock( $lock_fp, LOCK_UN ); if ( ! $holds ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing native flock() descriptor. fclose( $lock_fp ); } else { $this->regen_lock_fp = null; // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing native flock() descriptor. fclose( $lock_fp ); } if ( ! empty( $paths['lock'] ) && file_exists( $paths['lock'] ) ) { wp_delete_file( $paths['lock'] ); } Headers::send_status_headers( 'MISS', 'miss' ); Status::set( 'MISS', 'miss' ); do_action( 'qwsc_cache_stat_miss' ); return $html; } /** Remove abandoned .tmp files older than 1 hour */ public function cleanup_stale_temps() { if ( ! is_dir( $this->cache_dir ) ) { return; } $now = time(); $it = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $this->cache_dir, \FilesystemIterator::SKIP_DOTS ) ); foreach ( $it as $file ) { if ( ! $file->isFile() ) { continue; } $name = $file->getFilename(); if ( substr( $name, -4 ) === '.tmp' || substr( $name, -5 ) === '.lock' ) { if ( ( $now - $file->getMTime() ) > \HOUR_IN_SECONDS ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- cache tree cleanup via RecursiveIteratorIterator. $path = $file->getPathname(); if ( is_file( $path ) ) { wp_delete_file( $path ); } } } } } }