init(); } self::$request_headers = self::get_request_headers(); if (self::is_index()) { self::$settings = Fastless_Cache_Disk::get_settings(! self::$started); } elseif (class_exists('Fastless_Cache')) { self::$settings = Fastless_Cache::get_settings(! self::$started); Fastless_Cache::$options = self::$settings; // Deprecated in 1.5.0. } self::$started = (! empty(self::$settings)) ? true : false; // A late start never enters the drop-in's buffering branch, so without // this the request that regenerates a missing or outdated settings file // would not be cached either and a second visitor pays full price too. // Guarded to the cases that are safe: the first start, a frontend page, // the drop-in actually being in place (otherwise cache files could be // written but never delivered), and no buffering already active. if ($first_start && self::$started && ! self::$buffering && did_action('init') && self::is_index() && class_exists('Fastless_Cache') && Fastless_Cache::is_cache_active() ) { self::start_buffering(); } } /** * Whether the cache engine should start. * * @return bool True if the cache engine should start, false otherwise. */ public static function should_start() { $valid_engine_running = (self::$started && (! is_multisite() || ! ms_is_switched())); $early_ajax_request = (defined('DOING_AJAX') && DOING_AJAX && ! class_exists('Fastless_Cache')); $rest_request = (defined('REST_REQUEST') && REST_REQUEST); $xmlrpc_request = (defined('XMLRPC_REQUEST') && XMLRPC_REQUEST); $request_uri = isset($_SERVER['REQUEST_URI']) ? self::wp_unslash_safe($_SERVER['REQUEST_URI']) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- uses Fastless_Cache_Engine::wp_unslash_safe() which calls wp_unslash() when available, stripslashes() in early bootstrap context. $bad_request_uri = (str_replace(array('.ico', '.txt', '.xml', '.xsl'), '', $request_uri) !== $request_uri); if ($valid_engine_running || $early_ajax_request || $rest_request || $xmlrpc_request || $bad_request_uri) { return false; } return true; } /** * Start the output buffering. */ public static function start_buffering() { ob_start(self::class . '::end_buffering'); self::$buffering = true; } /** * End the output buffering and maybe cache the page. * * @param string $contents Contents from the output buffer. * @param int $phase Bitmask of the PHP_OUTPUT_HANDLER_* constants. * @return string Unmodified contents from the output buffer. */ private static function end_buffering($contents, $phase) { if ($phase & PHP_OUTPUT_HANDLER_FINAL || $phase & PHP_OUTPUT_HANDLER_END) { if (self::is_cacheable($contents) && ! self::bypass_cache()) { Fastless_Cache_Disk::cache_page($contents); } } return $contents; } /** * Sanitize server input string. * * @param string $str Input string. * @param bool $strict Strictly sanitized. * @return string Sanitized input string. */ public static function sanitize_server_input($str, $strict = true) { if (is_object($str) || is_array($str)) { return ''; } $str = (string) $str; if (0 === strlen($str)) { return ''; } $filtered = preg_replace('/[\r\n\t ]+/', ' ', $str); $filtered = trim($filtered); if ($strict) { $found = false; while (preg_match('/%[a-f0-9]{2}/i', $filtered, $match)) { $filtered = str_replace($match[0], '', $filtered); $found = true; } if ($found) { $filtered = trim(preg_replace('/ +/', ' ', $filtered)); } } return $filtered; } /** * Safely unslash a string, using wp_unslash() when available. * * In the early bootstrap context (advanced-cache.php drop-in), WordPress * functions are not loaded yet, so we fall back to stripslashes_deep() * to match WordPress's wp_unslash() behavior. * * @param string|array $value Input value. * @return string|array Unslashes value. */ public static function wp_unslash_safe($value) { if (function_exists('wp_unslash')) { return wp_unslash($value); } return self::stripslashes_deep($value); } /** * Recursively strip slashes from strings in a value. * * Mirrors WordPress's stripslashes_deep(). * * @param mixed $value Input value. * @return mixed Value with slashes stripped. */ private static function stripslashes_deep($value) { if (is_array($value)) { foreach ($value as $key => $val) { $value[$key] = self::stripslashes_deep($val); } return $value; } if (is_object($value)) { $vars = get_object_vars($value); foreach ($vars as $key => $val) { $value->$key = self::stripslashes_deep($val); } return $value; } return stripslashes($value); } /** * Safely parse URL, using wp_parse_url() when available. * * In the early bootstrap context (advanced-cache.php drop-in), WordPress * functions are not loaded yet, so we fall back to a wp_parse_url() * compatible implementation. * * @param string $url URL to parse. * @param int $component Specific component to retrieve. * @return mixed Parsed URL components or specific component. */ public static function wp_parse_url_safe($url, $component = -1) { if (function_exists('wp_parse_url')) { return wp_parse_url($url, $component); } // Fallback implementation matching WordPress's wp_parse_url(). $to_unset = array(); $url = (string) $url; if (strpos($url, '//') === 0) { $to_unset[] = 'scheme'; $url = 'placeholder:' . $url; } elseif (strpos($url, '/') === 0) { $to_unset[] = 'scheme'; $to_unset[] = 'host'; $url = 'placeholder://placeholder' . $url; } $parts = parse_url($url); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- fallback for early bootstrap context where wp_parse_url() is not yet loaded. if (false === $parts) { return $parts; } // Remove the placeholder values. foreach ($to_unset as $key) { unset($parts[$key]); } // Return specific component or full array. if (-1 === $component) { return $parts; } $translation = array( PHP_URL_SCHEME => 'scheme', PHP_URL_HOST => 'host', PHP_URL_PORT => 'port', PHP_URL_USER => 'user', PHP_URL_PASS => 'pass', PHP_URL_PATH => 'path', PHP_URL_QUERY => 'query', PHP_URL_FRAGMENT => 'fragment', ); $key = $translation[$component] ?? false; if (false !== $key && isset($parts[$key])) { return $parts[$key]; } return null; } /** * Validate and sanitize the Host header for early bootstrap context. * * WordPress sanitizers are not available yet, so we implement strict * hostname validation here. On failure, return empty string to bypass cache. * * @param string $host Raw Host header value. * @return string Validated lowercase hostname without port, or '' on failure. */ private static function validate_host_header($host) { $host = (string) $host; // Strip port if present. if (false !== strpos($host, ':')) { $host = substr($host, 0, strpos($host, ':')); } $host = strtolower(trim($host)); // Empty or too long. if ('' === $host || strlen($host) > 253) { return ''; } // No consecutive dots, no leading/trailing dot or hyphen. if (strpos($host, '..') !== false || $host[0] === '.' || $host[0] === '-' || substr($host, -1) === '.' || substr($host, -1) === '-') { return ''; } // Hostname regex: labels separated by dots, each label alphanumeric + hyphen, not starting/ending with hyphen. if (! preg_match('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/', $host)) { return ''; } return $host; } /** * Validate and sanitize the Request URI for early bootstrap context. * * WordPress sanitizers are not available yet. Reject path traversal, * null bytes, backslashes. Normalize to a safe path. * * @param string $uri Raw REQUEST_URI value. * @return string Validated path, or '' on failure. */ public static function validate_request_uri($uri) { $uri = (string) $uri; // Reject null bytes and backslashes immediately. if (strpos($uri, "\0") !== false || strpos($uri, '\\') !== false) { return ''; } // Parse URL to get path component only (strip query string and fragment). $parsed = self::wp_parse_url_safe($uri); $path = isset($parsed['path']) ? (string) $parsed['path'] : ''; // Empty path becomes '/'. if ('' === $path) { return '/'; } // Path must start with /. if ($path[0] !== '/') { return ''; } // Reject path traversal attempts. if (strpos($path, '..') !== false) { return ''; } // Length sanity check. if (strlen($path) > 2048) { return ''; } // Normalize: collapse multiple slashes, but keep leading slash. $path = preg_replace('#/{2,}#', '/', $path); return $path; } /** * Validate SERVER_PROTOCOL against allowlist. * * @param string $protocol Raw SERVER_PROTOCOL value. * @return string Validated protocol or safe fallback. */ private static function validate_server_protocol($protocol) { $protocol = (string) $protocol; $allowed = array('HTTP/1.0', 'HTTP/1.1', 'HTTP/2', 'HTTP/2.0'); if (in_array($protocol, $allowed, true)) { return $protocol; } return 'HTTP/1.1'; } /** * Sanitize header value for early bootstrap: length cap + strip control chars. * * @param string $value Raw header value. * @param int $max_length Maximum allowed length. * @return string Sanitized value. */ private static function sanitize_header_value($value, $max_length = 4096) { $value = (string) $value; // Strip control characters (except tab, which we normalize to space). $value = preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value); // Cap length. if (strlen($value) > $max_length) { $value = substr($value, 0, $max_length); } return trim($value); } /** * Get the required HTTP request headers from the current request. * * @return string[] An array of HTTP request headers with names as the keys. */ private static function get_request_headers() { if (! empty(self::$request_headers)) { return self::$request_headers; } $request_headers = function_exists('apache_request_headers') ? apache_request_headers() : array(); // Read each $__SERVER key once, validate/sanitize, and store in local variables. // This removes most phpcs:ignore flags and centralizes early-bootstrap input handling. // Use wp_unslash_safe() to handle both early bootstrap and regular WordPress contexts. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- uses Fastless_Cache_Engine::wp_unslash_safe() which calls wp_unslash() when available, stripslashes() in early bootstrap. $http_host = isset($request_headers['Host']) ? $request_headers['Host'] : (isset($_SERVER['HTTP_HOST']) ? self::wp_unslash_safe($_SERVER['HTTP_HOST']) : ''); $validated_host = self::validate_host_header($http_host); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- uses Fastless_Cache_Engine::wp_unslash_safe() which calls wp_unslash() when available, stripslashes() in early bootstrap. $http_accept = isset($request_headers['Accept']) ? $request_headers['Accept'] : (isset($_SERVER['HTTP_ACCEPT']) ? self::wp_unslash_safe($_SERVER['HTTP_ACCEPT']) : ''); $sanitized_accept = self::sanitize_header_value($http_accept); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- uses Fastless_Cache_Engine::wp_unslash_safe() which calls wp_unslash() when available, stripslashes() in early bootstrap. $http_accept_encoding = isset($request_headers['Accept-Encoding']) ? $request_headers['Accept-Encoding'] : (isset($_SERVER['HTTP_ACCEPT_ENCODING']) ? self::wp_unslash_safe($_SERVER['HTTP_ACCEPT_ENCODING']) : ''); $sanitized_accept_enc = self::sanitize_header_value($http_accept_encoding); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- uses Fastless_Cache_Engine::wp_unslash_safe() which calls wp_unslash() when available, stripslashes() in early bootstrap. $http_user_agent = isset($request_headers['User-Agent']) ? $request_headers['User-Agent'] : (isset($_SERVER['HTTP_USER_AGENT']) ? self::wp_unslash_safe($_SERVER['HTTP_USER_AGENT']) : ''); $sanitized_ua = self::sanitize_header_value($http_user_agent); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- uses Fastless_Cache_Engine::wp_unslash_safe() which calls wp_unslash() when available, stripslashes() in early bootstrap. $http_if_modified = isset($request_headers['If-Modified-Since']) ? $request_headers['If-Modified-Since'] : (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) ? self::wp_unslash_safe($_SERVER['HTTP_IF_MODIFIED_SINCE']) : ''); $sanitized_if_mod = self::sanitize_header_value($http_if_modified); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- uses Fastless_Cache_Engine::wp_unslash_safe() which calls wp_unslash() when available, stripslashes() in early bootstrap. $http_if_none_match = isset($request_headers['If-None-Match']) ? $request_headers['If-None-Match'] : (isset($_SERVER['HTTP_IF_NONE_MATCH']) ? self::wp_unslash_safe($_SERVER['HTTP_IF_NONE_MATCH']) : ''); $sanitized_if_none = self::sanitize_header_value($http_if_none_match); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- uses Fastless_Cache_Engine::wp_unslash_safe() which calls wp_unslash() when available, stripslashes() in early bootstrap. $http_x_fwd_proto = isset($request_headers['X-Forwarded-Proto']) ? $request_headers['X-Forwarded-Proto'] : (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) ? self::wp_unslash_safe($_SERVER['HTTP_X_FORWARDED_PROTO']) : ''); $sanitized_xfp = self::sanitize_header_value($http_x_fwd_proto); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- uses Fastless_Cache_Engine::wp_unslash_safe() which calls wp_unslash() when available, stripslashes() in early bootstrap. $http_x_fwd_scheme = isset($request_headers['X-Forwarded-Scheme']) ? $request_headers['X-Forwarded-Scheme'] : (isset($_SERVER['HTTP_X_FORWARDED_SCHEME']) ? self::wp_unslash_safe($_SERVER['HTTP_X_FORWARDED_SCHEME']) : ''); $sanitized_xfs = self::sanitize_header_value($http_x_fwd_scheme); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- uses Fastless_Cache_Engine::wp_unslash_safe() which calls wp_unslash() when available, stripslashes() in early bootstrap. $server_protocol = isset($_SERVER['SERVER_PROTOCOL']) ? self::wp_unslash_safe($_SERVER['SERVER_PROTOCOL']) : ''; $validated_protocol = self::validate_server_protocol($server_protocol); $request_headers = array( 'Accept' => $sanitized_accept, 'Accept-Encoding' => $sanitized_accept_enc, 'Host' => $validated_host, 'If-Modified-Since' => $sanitized_if_mod, 'If-None-Match' => $sanitized_if_none, 'User-Agent' => $sanitized_ua, 'X-Forwarded-Proto' => $sanitized_xfp, 'X-Forwarded-Scheme' => $sanitized_xfs, 'SERVER_PROTOCOL' => $validated_protocol, ); return $request_headers; } /** * Whether the script being executed is the installation directory index file. * * This uses $_SERVER['SCRIPT_NAME'] instead of $_SERVER['SCRIPT_FILENAME'] * because it is in the CGI/1.1 specification. It checks whether * FASTLESS_CACHE_INDEX_FILE ends with $_SERVER['SCRIPT_NAME']. * * @return bool True if the script being executed is the index file, false if not. */ private static function is_index() { if (! defined('FASTLESS_CACHE_INDEX_FILE')) { return false; } if (isset($_SERVER['SCRIPT_NAME'])) { // On Windows, ABSPATH (and therefore FASTLESS_CACHE_INDEX_FILE) // carries backslashes while SCRIPT_NAME always uses forward // slashes, so both sides are normalized before the suffix // comparison or it never matches there and the engine never // starts from the drop-in. wp_normalize_path() cannot be used: // WordPress is not loaded yet when the drop-in runs. $index_file = str_replace('\\', '/', FASTLESS_CACHE_INDEX_FILE); $script_name = str_replace('\\', '/', self::wp_unslash_safe($_SERVER['SCRIPT_NAME'])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- uses Fastless_Cache_Engine::wp_unslash_safe() which calls wp_unslash() when available, stripslashes() in early bootstrap. // Validate SCRIPT_NAME: must start with /, no null bytes, no backslashes, no path traversal. $script_name = (string) $script_name; if (strpos($script_name, "\0") !== false || strpos($script_name, '\\') !== false || strpos($script_name, '..') !== false || $script_name[0] !== '/' || strlen($script_name) > 2048) { return false; } $script_name_length = strlen($script_name); if (substr($index_file, -$script_name_length, $script_name_length) === $script_name) { return true; } } return false; } /** * Whether the contents from the output buffer can be cached. * * @param string $contents Contents from the output buffer. * @return bool True if contents from the output buffer are cacheable, false if not. */ public static function is_cacheable($contents) { if (! is_string($contents)) { return false; } # Return true if we have an HTML tag, an "HTML" doctype, and no XSL stylesheet. # The following checks are nested so that if any of them fail, we can return # false immediately without running the others. $has_html_tag = (stripos($contents, ' tag (with # question marks next to the brackets) before # the doctype. $html_doctype_regex = '/^\s*(<\?xml.+\?>)?\s*/i'; $has_html_doctype = preg_match($html_doctype_regex, $contents); if ($has_html_doctype) { $has_xsl_stylesheet = (stripos($contents, ' $value) { // phpcs:ignore WordPress.Security.NonceVerification -- read-only inspection of request data for cache key/bypass decisions; no state change occurs here, so no nonce applies. if (in_array($key, $excluded_queries)) { return true; } } } // A parameter the cache cannot represent must bypass it, otherwise // store actions such as ?add-to-cart=123 — and the sort and filter // links on a cached shop page — silently do nothing. Tracking // parameters are ignored, so campaign links still hit the cache; // the sort/filter parameters get their own cache versions only when // query caching is enabled. $represented_queries = self::$default_ignore_queries; if (! empty(self::$settings['query_cache'])) { $included_queries = ! empty(self::$settings['included_query_strings_list']) ? (array) self::$settings['included_query_strings_list'] : self::parse_exclusion_list((string) self::$settings['included_query_strings']); $represented_queries = array_merge( $represented_queries, self::$default_include_queries, $included_queries ); } foreach (array_keys($_GET) as $query_key) { // phpcs:ignore WordPress.Security.NonceVerification -- read-only inspection of request data for cache key/bypass decisions; no state change occurs here, so no nonce applies. if (! in_array($query_key, $represented_queries, true)) { return true; } } } // Cookie exclusions. The built-in bypasses (login, password protection, // comment author) are always merged in: a custom list must never be able // to drop them, or logged-in visitors would be served cached pages. if (! empty($_COOKIE)) { $cookies_regex = ! empty(self::$settings['excluded_cookies_regex']) ? (string) self::$settings['excluded_cookies_regex'] : self::build_exclusion_regex(array_merge( array('wp-postpass*', 'wordpress_logged_in*', 'comment_author*'), self::parse_exclusion_list((string) self::$settings['excluded_cookies']) )); if ('' !== $cookies_regex) { foreach ($_COOKIE as $key => $value) { if (preg_match($cookies_regex, (string) $key)) { return true; } } } } // Shoppers with items in their cart get a fresh page unless the store has // opted into caching them (WooCommerce's AJAX fragments update the cart // client-side). A "0" count means the cart is empty. if (empty(self::$settings['woocommerce_cart_cache'])) { foreach (array('woocommerce_items_in_cart', 'woocommerce_cart_hash') as $cart_cookie) { if (! empty($_COOKIE[$cart_cookie])) { return true; } } } // Never cache store transactional pages (WooCommerce, EDD). if (self::is_woocommerce_uncacheable() || self::is_edd_uncacheable()) { return true; } // When the output buffering is ending. if (class_exists('WP')) { if (is_admin() || is_feed() || is_trackback() || is_robots() || is_preview() || post_password_required() || self::exclude_search()) { return true; } } return false; } /** * Whether the current request is a WooCommerce page that must never be cached. * * Covers the cart, checkout and my-account pages, both through the * conditional functions and through the configured page IDs. * * @return bool */ public static function is_woocommerce_uncacheable() { if (! function_exists('is_woocommerce')) { return false; } // Transactional pages only. Shop, product and product-category pages are // cacheable, and is_woocommerce() is true on all of them. if (is_cart() || is_checkout() || is_account_page()) { return true; } // Account endpoints (/my-account/orders/, /my-account/view-order/12/…). if (function_exists('is_wc_endpoint_url') && is_wc_endpoint_url()) { return true; } // The order-received and order-pay checkout endpoints. if (function_exists('is_order_received_page') && is_order_received_page()) { return true; } if (self::is_woocommerce_page_id_match()) { return true; } return self::is_woocommerce_shortcode_page(); } /** * Whether the queried page renders a transactional WooCommerce shortcode. * * The cart, checkout and my-account pages are usually excluded by page ID, * but the same shortcodes can be embedded anywhere. * * @return bool */ private static function is_woocommerce_shortcode_page() { // Only singular views can carry a page shortcode; scanning an archive's // queried object (a term ID) would read the wrong post. if (! function_exists('is_singular') || ! is_singular()) { return false; } if (null !== self::$woo_shortcode_page) { return self::$woo_shortcode_page; } self::$woo_shortcode_page = false; if (! function_exists('get_queried_object_id') || ! function_exists('get_post_field') || ! function_exists('has_shortcode')) { return self::$woo_shortcode_page; } $post_id = (int) get_queried_object_id(); if (! $post_id) { return self::$woo_shortcode_page; } $content = get_post_field('post_content', $post_id); if (! is_string($content) || '' === $content) { return self::$woo_shortcode_page; } foreach (array('woocommerce_cart', 'woocommerce_checkout', 'woocommerce_my_account') as $shortcode) { if (has_shortcode($content, $shortcode)) { self::$woo_shortcode_page = true; break; } } return self::$woo_shortcode_page; } /** * Whether the current request is an Easy Digital Downloads page that must * never be cached. * * @return bool */ public static function is_edd_uncacheable() { if (! function_exists('edd_is_checkout')) { return false; } if (edd_is_checkout() || (function_exists('edd_is_purchase_history_page') && edd_is_purchase_history_page()) || (function_exists('edd_is_success_page') && edd_is_success_page()) ) { return true; } return false; } /** * Whether the queried object is a configured WooCommerce cart, checkout or * my-account page. * * @return bool */ private static function is_woocommerce_page_id_match() { if (! function_exists('get_queried_object_id')) { return false; } $queried_id = (int) get_queried_object_id(); if (! $queried_id) { return false; } // Read the three options once per request instead of on every check. if (null === self::$woo_page_ids) { self::$woo_page_ids = array( (int) get_option('woocommerce_cart_page_id', 0), (int) get_option('woocommerce_checkout_page_id', 0), (int) get_option('woocommerce_myaccount_page_id', 0), ); } return in_array($queried_id, self::$woo_page_ids, true); } /** * Whether to exclude search queries from the cache. * * @return bool True if search queries should be excluded from the cache, false if not. */ private static function exclude_search() { /** * Filters whether search queries should be excluded from the cache. * * This requires pretty search URLs. For example, https://example.com/search/query/ * instead of https://example.com/?s=query. The search cache will not be * automatically cleared. * * @param bool $exclude_search True if search queries should be excluded from the cache, false if not. Default * is the value returned by is_search(). */ $exclude_search = apply_filters('fastless_cache_exclude_search', is_search()); return $exclude_search; } /** * Whether the cache should be bypassed. * * @return bool True if the cache should be bypassed, false otherwise. */ private static function bypass_cache() { /** * Filters whether the cache should be bypassed. * * @param bool $bypass_cache True if the cache should be bypassed, false if not. Default is the value * returned by Fastless_Cache_Engine::is_excluded(). */ $bypass_cache = apply_filters('fastless_cache_bypass_cache', self::is_excluded()); return $bypass_cache; } /** * Build the HTTP response headers for a delivered cache file. * * @param int $mtime Cache file modification time (Unix timestamp). * @param int $size Cache file size in bytes. * @param array $settings Plugin settings. * @param bool $suppress_content_length (Optional) Skip Content-Length when the server may rewrite the body (host-level compression). Default false. * @return string[] Header lines as "Name: value". */ public static function build_cache_headers($mtime, $size, $settings, $suppress_content_length = false) { $charset = (is_array($settings) && ! empty($settings['blog_charset'])) ? $settings['blog_charset'] : 'UTF-8'; $mtime = (int) $mtime; $size = (int) $size; $headers = array('Content-Type: text/html; charset=' . $charset); // Only advertise a length we actually know and that the server will not // rewrite: a wrong Content-Length truncates or hangs the response. if ($size > 0 && ! $suppress_content_length) { $headers[] = 'Content-Length: ' . $size; } $headers[] = 'ETag: "' . dechex($mtime) . '-' . dechex($size) . '"'; $headers[] = 'Last-Modified: ' . gmdate('D, d M Y H:i:s', $mtime) . ' GMT'; if (is_array($settings) && ! empty($settings['browser_cache_max_age'])) { $seconds = (int) $settings['browser_cache_max_age'] * 3600; if ($seconds > 0) { $headers[] = 'Cache-Control: public, max-age=' . $seconds; } } return $headers; } /** * Whether a conditional request means the cached page is still fresh. * * Follows RFC 7232: a present If-None-Match takes precedence over * If-Modified-Since and is compared against the ETag (supporting the "*" * wildcard, weak validators and comma-separated lists). * * @param string $if_modified_since The If-Modified-Since header value. * @param string $if_none_match The If-None-Match header value. * @param string $etag The current ETag (including quotes). * @param int $mtime The cache file modification time. * @return bool True when a 304 is appropriate. */ public static function is_not_modified($if_modified_since, $if_none_match, $etag, $mtime) { $if_none_match = trim((string) $if_none_match); if ('' !== $if_none_match) { if ('*' === $if_none_match) { return true; } foreach (explode(',', $if_none_match) as $candidate) { $candidate = trim($candidate); if (0 === strpos($candidate, 'W/')) { $candidate = substr($candidate, 2); } if ($candidate === $etag) { return true; } } return false; } $if_modified_since = trim((string) $if_modified_since); return ('' !== $if_modified_since && strtotime($if_modified_since) >= (int) $mtime); } /** * Deliver the cached page for the current request. * * @return bool False if the cached page was not delivered. */ public static function deliver_cache() { $cache_file = Fastless_Cache_Disk::get_cache_file(); // Stat the file once and reuse the mtime for the expiry check, the // conditional request and the response headers. $cache_file_mtime = Fastless_Cache_Disk::cache_exists($cache_file) ? @filemtime($cache_file) : false; if (false !== $cache_file_mtime && ! Fastless_Cache_Disk::cache_expired($cache_file, $cache_file_mtime) && ! self::bypass_cache()) { header('X-Cache-Handler: fastless-cache-engine'); $cache_file_size = @filesize($cache_file); $cache_headers = self::build_cache_headers($cache_file_mtime, $cache_file_size, self::$settings, (bool) ini_get('zlib.output_compression')); // Send the validators first so a 304 still carries them, then answer // the conditional request before any entity headers are emitted. $etag = ''; foreach ($cache_headers as $cache_header) { if (0 === strpos($cache_header, 'ETag: ')) { $etag = substr($cache_header, strlen('ETag: ')); header($cache_header); } elseif (0 === strpos($cache_header, 'Last-Modified: ')) { header($cache_header); } } if (self::is_not_modified( self::$request_headers['If-Modified-Since'], self::$request_headers['If-None-Match'], $etag, $cache_file_mtime )) { header(self::$request_headers['SERVER_PROTOCOL'] . ' 304 Not Modified', true, 304); exit; // Deliver empty body. } foreach ($cache_headers as $cache_header) { header($cache_header); } // Cached variants are picked per request (compression, device), // so shared caches must be told what the response varies on or // they can hand a variant to a client that cannot use it. $vary = array(); if (! empty(Fastless_Cache_Engine::$settings['compress_cache'])) { $vary[] = 'Accept-Encoding'; } if (! empty(Fastless_Cache_Engine::$settings['mobile_cache'])) { $vary[] = 'User-Agent'; } if (! empty($vary)) { header('Vary: ' . implode(', ', $vary)); } switch (substr($cache_file, -2, 2)) { case 'br': header('Content-Encoding: br'); break; case 'gz': header('Content-Encoding: gzip'); break; } readfile($cache_file); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- serving the cache file must stay on the hot path; WP_Filesystem is unavailable in the drop-in context and would add per-hit overhead. exit; } return false; } }