matches the given ETag. * * @param string $etag The ETag to compare against. * * @return bool True if the ETag matches, false otherwise. */ public function _check_match( $etag ) { if ( ! empty( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) { $if_none_match = htmlspecialchars( stripslashes( $_SERVER['HTTP_IF_NONE_MATCH'] ) ); // phpcs:ignore $client_etags = explode( ',', $if_none_match ); foreach ( $client_etags as $client_etag ) { $client_etag = trim( $client_etag ); if ( $etag === $client_etag ) { return true; } } } return false; } /** * Executes bad behavior protection if configured. * * @return void */ public function _bad_behavior() { $bb_file = $this->_config->get_string( 'pgcache.bad_behavior_path' ); if ( '' === $bb_file ) { return; } /** * Allowlist gate for the admin-configured Bad Behavior plugin path *. The legacy code called * `require_once $this->_config->get_string('pgcache.bad_behavior_path')` * with zero validation -- any admin-settable string flowed straight * into require_once. Mass-assignment writes to this key (handled * separately by sec-fix-mass-assignment) gave a subscriber-reachable * RCE primitive. * * The validator below enforces: * - realpath() canonicalization succeeds (file must exist) * - the resolved path lives under WP_PLUGIN_DIR, which is the * correct boundary because operators have multiple legitimate * install layouts (`bad-behavior`, `bad-behavior-mu`, etc.). * `/tmp/evil.php`, `/etc/passwd`, and anything else outside * `WP_PLUGIN_DIR` are rejected. The W3TC plugin directory is * itself under `WP_PLUGIN_DIR` and is therefore NOT excluded * by this prefix check; the realpath + `is_file()` checks * plus the fact that this method is only called from the * page-cache start path make a self-include via this key a * non-issue (no W3TC file is a sensible "Bad Behavior" hook). * * Failures are logged via Util_Debug::log('pgcache', ...). User- * influenced path strings pass through the local * `$sanitize_for_log` closure below — it escapes CR/LF to the * literal sequences `\r` / `\n` and replaces any remaining * control byte (`0x00`-`0x1F` / `0x7F`) with `?`, so a crafted * config value cannot forge additional log lines (log-injection). * Kept inline rather than promoted to a Util_Debug helper because * this is the only log site in the file-inclusion fix that * concatenates an admin-controlled string; promoting it would * change the Util_Debug API surface for no other consumer. * * @since 2.10.0 */ $sanitize_for_log = static function ( $w3tc_value ) { $w3tc_value = (string) $w3tc_value; $w3tc_value = \str_replace( array( "\r", "\n" ), array( '\\r', '\\n' ), $w3tc_value ); return \preg_replace( '/[\x00-\x1F\x7F]/', '?', $w3tc_value ); }; $real = \realpath( $bb_file ); if ( false === $real || ! \is_file( $real ) ) { if ( \class_exists( '\W3TC\Util_Debug' ) ) { Util_Debug::log( 'pgcache', 'bad_behavior_path rejected: realpath() failed for ' . $sanitize_for_log( $bb_file ) ); } return; } $plugin_root = \defined( 'WP_PLUGIN_DIR' ) ? \realpath( WP_PLUGIN_DIR ) : false; if ( false === $plugin_root ) { if ( \class_exists( '\W3TC\Util_Debug' ) ) { Util_Debug::log( 'pgcache', 'bad_behavior_path rejected: WP_PLUGIN_DIR not resolvable' ); } return; } if ( 0 !== \strpos( $real, $plugin_root . DIRECTORY_SEPARATOR ) ) { if ( \class_exists( '\W3TC\Util_Debug' ) ) { Util_Debug::log( 'pgcache', 'bad_behavior_path rejected: not under WP_PLUGIN_DIR (' . $sanitize_for_log( $real ) . ')' ); } return; } require_once $real; } /** * Parses dynamic content within the provided buffer. * * Security model for the registered-callback dispatcher: * - Layer 1: dispatch goes through a registered-callback registry * (the `w3tc_dynamic_callbacks` filter) — raw PHP / file paths are * refused; only `call:` payloads dispatch. * - Layer 2: each tag must carry an HMAC computed at cache-write time * using the HMAC key returned by `_dynamic_hmac_key()` (see that * helper's docblock for why it bypasses `wp_salt()`). Read-time * recomputes and rejects on mismatch. The HMAC is appended to * the cached tag by `_sign_dynamic_tags()` during the output- * buffering pass. * - Layer 3: if `W3TC_DYNAMIC_SECURITY` is undefined / empty / `1`, * no dispatch happens at all — the buffer is returned untouched. * * @since 2.10.0 * * @param string $buffer The content buffer to parse. * * @return string The buffer with parsed dynamic content. */ public function _parse_dynamic( $buffer ) { // The W3TC_DYNAMIC_SECURITY constant should be a unique string and not an int or boolean. if ( ! defined( 'W3TC_DYNAMIC_SECURITY' ) || empty( W3TC_DYNAMIC_SECURITY ) || 1 === (int) W3TC_DYNAMIC_SECURITY ) { return $buffer; } $security = preg_quote( W3TC_DYNAMIC_SECURITY, '~' ); $buffer = preg_replace_callback( '~(.*)~Uis', array( $this, '_parse_dynamic_mfunc', ), $buffer ); $buffer = preg_replace_callback( '~(.*)~Uis', array( $this, '_parse_dynamic_mclude', ), $buffer ); return $buffer; } /** * Returns the registry of dynamic callback slugs → callables. * * **Callback contract.** Every registered callable is invoked with * two arguments: `( array $args, string $kind )`. `$args` is the * JSON-decoded payload from the tag (always an array); `$kind` is * the literal string `'mfunc'` or `'mclude'`. The second argument * lets a callback serve both kinds with different rendering, or * assert on `$kind` defensively. Callbacks may declare only * `$args` if they don't need it — PHP discards the extra * positional — but a typed/strict-mode signature MUST accept both. * * Themes and plugins register dispatchable callbacks via: * * add_filter( 'w3tc_dynamic_callbacks', function( $cbs ) { * $cbs['my_slug'] = function( $args, $kind ) { * return '' . esc_html( $args['user'] ?? '' ) . ''; * }; * return $cbs; * } ); * * Then emit a tag like: * * * * The HMAC envelope (`hmac:`) is added automatically at * cache-write time; emitters MUST NOT compute it themselves. * * **Migrating a legacy tag.** A pre-2.10.0 tag that embedded raw PHP, e.g. * ` echo my_widget(); `, is no * longer dispatched (it renders as empty output). Move the logic into a * registered callback and emit the `call:` form instead: * * add_filter( 'w3tc_dynamic_callbacks', function( $cbs ) { * $cbs['my_widget'] = function( $args, $kind ) { * return my_widget(); * }; * return $cbs; * } ); * * * * **Cache-miss fallback content.** Use the *inline-header* form (with the * `call:slug` descriptor inside the opening comment) if you want HTML * placed between the comments to survive a cache miss. The *body-form* * (descriptor between the comments) is supported for back-compat but its * body is overwritten by `_sign_dynamic_tags()` at write time — anything * you placed there as fallback markup will be wiped before the cached * file lands on disk. * * @since 2.10.0 * * @return array Slug → callable(array $args, string $kind): string */ private function _get_dynamic_callbacks() { if ( ! \function_exists( 'apply_filters' ) ) { return array(); } $callbacks = \apply_filters( 'w3tc_dynamic_callbacks', array() ); return \is_array( $callbacks ) ? $callbacks : array(); } /** * Whether mu-plugins have had a chance to register dynamic callbacks. * * `advanced-cache.php` may serve a dynamic cache hit before * `muplugins_loaded`, so an empty registry there is expected rather * than a misconfiguration worth logging. * * @since 2.10.0 * * @return bool */ private function _dynamic_callbacks_may_be_registered() { return \function_exists( 'did_action' ) && \did_action( 'muplugins_loaded' ); } /** * Returns the HMAC secret used to sign dynamic-fragment payloads. * * Derived directly from `AUTH_KEY` + `AUTH_SALT` rather than * `wp_salt('auth')` because the verifier runs from * `advanced-cache.php`, which `wp-settings.php` loads *before* * `wp-includes/pluggable.php` — so `wp_salt()` is undefined on * the regular cache-HIT path for every non-`file_generic` cache * engine (Disk: Basic, memcached, redis, APC, XCache). The * signer runs from `ob_callback()` *after* pluggable is loaded, * so calling `wp_salt('auth')` on the write side would derive * the same constants but the verifier could not match it. * * Using the underlying constants directly gives the same site- * bound rotation behaviour as `wp_salt('auth')` (`wp_salt` is * itself a hash over `AUTH_KEY . AUTH_SALT` for the `auth` * scheme) while remaining stable across the * `advanced-cache.php` → `pluggable.php` boundary and across * CLI test invocations where WordPress is not loaded at all. * * When neither constant is defined (a development site whose * `wp-config.php` was generated without salts), the helper * falls back to `W3TC_DYNAMIC_SECURITY` so the HMAC remains * deterministic for that install rather than degenerating to * an empty key. * * @since 2.10.0 * * @return string */ private function _dynamic_hmac_key() { $w3tc_key = defined( 'AUTH_KEY' ) ? (string) AUTH_KEY : ''; $salt = defined( 'AUTH_SALT' ) ? (string) AUTH_SALT : ''; if ( '' === $w3tc_key && '' === $salt ) { return defined( 'W3TC_DYNAMIC_SECURITY' ) ? (string) W3TC_DYNAMIC_SECURITY : ''; } return 'w3tc-dynamic|' . $w3tc_key . '|' . $salt; } /** * Computes the HMAC for a (kind, slug, args-json) tuple. * * @since 2.10.0 * * @param string $kind Tag kind, 'mfunc' or 'mclude'. * @param string $slug Callback slug. * @param string $args_json The JSON args string in its **canonical * form** — i.e. with leading/trailing * whitespace trimmed. Both the signing path * (`_sign_dynamic_tags`) and the verifying * path (`_parse_dynamic_header` → * `_dispatch_dynamic`) apply the same * `trim()` before calling this helper, so * the HMAC is deterministic across write * and read for the same tag. Callers MUST * NOT pass an untrimmed value; doing so * would produce an HMAC that the verifier * rejects. * * @return string Lowercase hex sha256 HMAC. */ public function _dynamic_hmac( $kind, $slug, $args_json ) { return \hash_hmac( 'sha256', $kind . '|' . $slug . '|' . $args_json, $this->_dynamic_hmac_key() ); } /** * Parses a mfunc / mclude tag header into (slug, args_json, hmac). * * Accepts the safe form: * * call: hmac: * * Whitespace between segments is flexible; `` is the * substring between the slug token and `hmac:`. Returns `null` if * the tag is not in the safe form (no `call:` prefix or no `hmac:`). * * @since 2.10.0 * * @param string $header The raw payload header from the tag. * * @return array{slug:string,args_json:string,hmac:string}|null */ private function _parse_dynamic_header( $header ) { $header = trim( $header ); if ( ! preg_match( '~^call:([A-Za-z0-9_\-\.:]+)\s*(.*?)\s*hmac:([0-9a-f]{64})$~is', $header, $m ) ) { return null; } return array( 'slug' => $m[1], 'args_json' => trim( $m[2] ), 'hmac' => strtolower( $m[3] ), ); } /** * Decodes a tag's JSON args, returning an empty array for empty / invalid input. * * @since 2.10.0 * * @param string $args_json JSON string. * * @return array */ private function _decode_dynamic_args( $args_json ) { if ( '' === $args_json ) { return array(); } $decoded = \json_decode( $args_json, true ); return \is_array( $decoded ) ? $decoded : array(); } /** * Logs a deprecation notice when raw-PHP mfunc / file-path mclude tags * are seen at dispatch time. These tags are no longer executed — emitters * must migrate to the `call:` registered-callback form. * * @since 2.10.0 * * @param string $kind 'mfunc' or 'mclude'. * @param string $reason Human-readable reason. * * @return void */ private function _log_dynamic_deprecation( $kind, $reason ) { /** * Rate-limit identical (kind|reason) notices to once per 5 minutes * per site. On a high-traffic site, the first cache miss after this * patch ships re-renders every still-cached legacy tag through this * path; without a guard, `_doing_it_wrong()` floods admin notices * (when WP_DEBUG is on) and `error_log()` floods the system log on * every page load until the cache rolls over. The dedupe key uses * md5() rather than the raw string because $reason can contain HMACs * and arbitrary slugs, and transient keys are length-bounded. */ $dedupe_key = 'w3tc_dyn_dep_' . md5( (string) $kind . '|' . (string) $reason ); if ( \function_exists( 'get_site_transient' ) && \function_exists( 'set_site_transient' ) ) { if ( false !== \get_site_transient( $dedupe_key ) ) { return; } \set_site_transient( $dedupe_key, 1, 300 ); } if ( \function_exists( '_doing_it_wrong' ) ) { \_doing_it_wrong( \esc_html( $kind ), \esc_html( sprintf( /* translators: 1: tag kind (mfunc/mclude), 2: reason. */ 'W3TC dynamic %1$s tag refused: %2$s. Raw PHP / file-path payloads are no longer dispatched; migrate to the registered-callback form () via the `w3tc_dynamic_callbacks` filter.', $kind, $reason ) ), '2.10.0' ); } elseif ( \function_exists( 'error_log' ) ) { \error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log sprintf( '[W3TC] dynamic %s tag refused: %s', $kind, $reason ) ); } } /** * Dispatches a verified, registered callback by slug. * * @since 2.10.0 * * @param string $kind 'mfunc' or 'mclude'. * @param string $slug Callback slug. * @param array $args Decoded JSON args. * * @return string The callback's HTML output, or an empty string when the tag cannot be dispatched. */ private function _dispatch_dynamic_callback( $kind, $slug, $args ) { $callbacks = $this->_get_dynamic_callbacks(); if ( empty( $callbacks ) ) { if ( $this->_dynamic_callbacks_may_be_registered() ) { $this->_log_dynamic_deprecation( $kind, sprintf( 'no callbacks registered (slug "%s")', $slug ) ); } return ''; } if ( ! isset( $callbacks[ $slug ] ) || ! \is_callable( $callbacks[ $slug ] ) ) { $this->_log_dynamic_deprecation( $kind, sprintf( 'unregistered slug "%s"', $slug ) ); return ''; } try { $w3tc_output = \call_user_func( $callbacks[ $slug ], $args, $kind ); } catch ( \Throwable $ex ) { // Log the exception class only (not its message) so the operator notice stays actionable without leaking detail. $this->_log_dynamic_deprecation( $kind, sprintf( 'slug "%s" raised %s', $slug, \get_class( $ex ) ) ); return ''; } if ( ! \is_string( $w3tc_output ) ) { /** * A callback that returns null/false/an array silently coerces to * an empty (or garbled) string and hides the bug from site owners * — the fragment just disappears. Surface it through the same * log channel as the deprecation path so the breakage is visible * (and rate-limited identically) before we coerce. */ $this->_log_dynamic_deprecation( $kind, sprintf( 'callback "%s" returned non-string %s', $slug, \gettype( $w3tc_output ) ) ); /** * `(string)` on an array or a non-Stringable object raises a PHP * warning and produces the literal `"Array"` / "Object of class … * could not be converted" sentinel. Both are useless in cached * HTML and noisy in the error log. Coerce scalars (which have * useful string forms — `null` → `''`, `false` → `''`, numbers * → the digits) but emit an empty string for the rest. */ if ( \is_scalar( $w3tc_output ) || ( \is_object( $w3tc_output ) && \method_exists( $w3tc_output, '__toString' ) ) ) { return (string) $w3tc_output; } return ''; } return $w3tc_output; } /** * Common dispatcher for mfunc / mclude tags. * * Enforces the three security layers: * 1. Refuses unless `W3TC_DYNAMIC_SECURITY` is defined (already gated * upstream by `_parse_dynamic()`). * 2. Refuses unless the payload is in the `call:` form with a * valid HMAC envelope. * 3. Refuses unless the slug is in the registered-callback registry. * * @since 2.10.0 * * @param string $kind 'mfunc' or 'mclude'. * @param array $matches preg_replace_callback matches: [0]=full, [1]=header, [2]=body. * * @return string Callback output, or an empty string when the tag is refused. */ private function _dispatch_dynamic( $kind, $matches ) { $header = isset( $matches[1] ) ? trim( $matches[1] ) : ''; $body = isset( $matches[2] ) ? trim( $matches[2] ) : ''; /** * Prefer the header for the call descriptor; fall back to the body for * the alternate "call:slug ... hmac:hex" form. */ $candidate = '' !== $header ? $header : $body; $parsed = $this->_parse_dynamic_header( $candidate ); if ( null === $parsed ) { // Render nothing to visitors; the operator log above records the tag to migrate. $this->_log_dynamic_deprecation( $kind, 'raw payload (no call:slug + hmac envelope)' ); return ''; } $expected = $this->_dynamic_hmac( $kind, $parsed['slug'], $parsed['args_json'] ); if ( ! \hash_equals( $expected, $parsed['hmac'] ) ) { $this->_log_dynamic_deprecation( $kind, 'HMAC mismatch' ); return ''; } return $this->_dispatch_dynamic_callback( $kind, $parsed['slug'], $this->_decode_dynamic_args( $parsed['args_json'] ) ); } /** * Dispatches a dynamic mfunc tag. * * Previously this method `eval()`'d arbitrary PHP from the tag payload. * That primitive has been removed; only registered callbacks dispatched * via `call:` + HMAC are honored. * * @since 2.10.0 * * @param array $matches The matches from the regular expression. * * @return string The callback's output, or an empty string when refused. */ public function _parse_dynamic_mfunc( $matches ) { return $this->_dispatch_dynamic( 'mfunc', $matches ); } /** * Dispatches a dynamic mclude tag. * * Previously this method `include`'d a relative file path supplied * inside the tag. That primitive has been removed; only registered * callbacks dispatched via `call:` + HMAC are honored. Plugins * that need to render an include's output should wrap it in a slug. * * @since 2.10.0 * * @param array $matches The matches from the regular expression. * * @return string The callback's output, or an empty string when refused. */ public function _parse_dynamic_mclude( $matches ) { return $this->_dispatch_dynamic( 'mclude', $matches ); } /** * Signs every dynamic mfunc / mclude tag in $buffer by appending an * `hmac:` envelope to the `call:` descriptor. Called once * before the buffer is written to the page cache so that later * read-time dispatch can verify integrity. * * Both inline-header form (`...`) * and between-tags / body form (`call:slug ...`) * are signed in place, because dispatch accepts both shapes. * * Tags whose payload is not in the safe `call:` form are left * unsigned — at dispatch time they fall through to the deprecation path, * render as empty output, and emit a rate-limited operator notice naming * the tag to migrate. * * @since 2.10.0 * * @param string $buffer Buffer containing rendered HTML. * * @return string Buffer with HMAC envelopes added. */ public function _sign_dynamic_tags( $buffer ) { if ( ! defined( 'W3TC_DYNAMIC_SECURITY' ) || empty( W3TC_DYNAMIC_SECURITY ) || 1 === (int) W3TC_DYNAMIC_SECURITY ) { return $buffer; } $security = preg_quote( W3TC_DYNAMIC_SECURITY, '~' ); $sign_one = function ( $kind ) { return function ( $matches ) use ( $kind ) { $header = isset( $matches[1] ) ? trim( $matches[1] ) : ''; $body_raw = isset( $matches[2] ) ? $matches[2] : ''; $body = trim( $body_raw ); // If the header already has a complete `call:slug ... hmac:hex` envelope, leave it alone. if ( null !== $this->_parse_dynamic_header( $header ) ) { return $matches[0]; } // Likewise, leave a fully-signed body-form alone. if ( '' === $header && null !== $this->_parse_dynamic_header( $body ) ) { return $matches[0]; } /** * Determine where the `call:` descriptor lives: in the header * (inline form) or in the body (between-tags form). Both forms are * honored at dispatch time, so both must be signed at write time. */ if ( preg_match( '~^call:([A-Za-z0-9_\-\.:]+)\s*(.*)$~is', $header, $m ) ) { $source = 'header'; } elseif ( '' === $header && preg_match( '~^call:([A-Za-z0-9_\-\.:]+)\s*(.*)$~is', $body, $m ) ) { $source = 'body'; } else { // Raw-PHP / file-path payload — leave unsigned; dispatch will refuse. return $matches[0]; } $slug = $m[1]; $args_json = trim( $m[2] ); $hmac = $this->_dynamic_hmac( $kind, $slug, $args_json ); $w3tc_descriptor = 'call:' . $slug; if ( '' !== $args_json ) { $w3tc_descriptor .= ' ' . $args_json; } $w3tc_descriptor .= ' hmac:' . $hmac; if ( 'header' === $source ) { return '' . $body_raw . ''; } return '' . $w3tc_descriptor . ''; }; }; /** * `\s*` (NOT `\s+`) between the security token and the closing * `-->` so we match the same set of tags as `_parse_dynamic` / * `_has_dynamic` (both use `(.*)-->`, accepting zero whitespace). * A tag like `call:slug...` * would otherwise be detected as dynamic at parse time but never * signed at write time — dispatch would then refuse it for a * missing HMAC envelope. */ $buffer = preg_replace_callback( '~(.*?)~is', $sign_one( 'mfunc' ), $buffer ); $buffer = preg_replace_callback( '~(.*?)~is', $sign_one( 'mclude' ), $buffer ); return $buffer; } /** * Checks if the buffer contains dynamic tags. * * @param string $buffer The content buffer to check. * * @return bool True if dynamic tags are present, false otherwise. */ public function _has_dynamic( $buffer ) { if ( ! defined( 'W3TC_DYNAMIC_SECURITY' ) || empty( W3TC_DYNAMIC_SECURITY ) || 1 === (int) W3TC_DYNAMIC_SECURITY ) { return false; } $security = preg_quote( W3TC_DYNAMIC_SECURITY, '~' ); return preg_match( '~(.*)~Uis', $buffer ); } /** * Determines if the content type is cacheable. * * @return bool True if the content type is cacheable, false otherwise. */ private function _is_cacheable_content_type() { $content_type = ''; $headers = headers_list(); foreach ( $headers as $header ) { $header = strtolower( $header ); $m = null; if ( preg_match( '~\s*content-type\s*:([^;]+)~', $header, $m ) ) { $content_type = trim( $m[1] ); } } $cache_headers = apply_filters( 'w3tc_is_cacheable_content_type', array( '', // redirects, they have only Location header set. 'application/json', 'text/html', 'text/xml', 'text/xsl', 'application/xhtml+xml', 'application/rss+xml', 'application/atom+xml', 'application/rdf+xml', 'application/xml', ) ); return in_array( $content_type, $cache_headers, true ); } /** * Preprocesses the request URI into its components. * * @return void */ private function _preprocess_request_uri() { $w3tc_p = explode( '?', $this->_request_uri, 2 ); $this->_request_url_fragments['path'] = $w3tc_p[0]; $this->_request_url_fragments['querystring'] = ( empty( $w3tc_p[1] ) ? '' : '?' . $w3tc_p[1] ); $this->_request_url_fragments = $this->_normalize_url_fragments( $this->_request_url_fragments ); } /** * Normalizes URL fragments. * * @param array $fragments { * The URL fragments to normalize. * * @type string $querystring The query string to normalize. * } * * @return array The normalized URL fragments. */ private function _normalize_url_fragments( $fragments ) { $fragments = w3tc_apply_filters( 'pagecache_normalize_url_fragments', $fragments ); $fragments['querystring'] = $this->_normalize_querystring( $fragments['querystring'] ); return $fragments; } /** * Normalizes the query string in a URL. * * @param string $querystring The query string to normalize. * * @return string The normalized query string. */ private function _normalize_querystring( $querystring ) { $ignore_qs = $this->_config->get_array( 'pgcache.accept.qs' ); $ignore_qs = w3tc_apply_filters( 'pagecache_extract_accept_qs', $ignore_qs ); Util_Rule::array_trim( $ignore_qs ); if ( empty( $ignore_qs ) || empty( $querystring ) ) { return $querystring; } $querystring_naked = substr( $querystring, 1 ); foreach ( $ignore_qs as $qs ) { $m = null; if ( strpos( $qs, '=' ) === false ) { $regexp = Util_Environment::preg_quote( str_replace( '+', ' ', $qs ) ); if ( @preg_match( "~^(.*?&|)$regexp(=[^&]*)?(&.*|)$~i", $querystring_naked, $m ) ) { $querystring_naked = $m[1] . $m[3]; } } else { $regexp = Util_Environment::preg_quote( str_replace( '+', ' ', $qs ) ); if ( @preg_match( "~^(.*?&|)$regexp(&.*|)$~i", $querystring_naked, $m ) ) { $querystring_naked = $m[1] . $m[2]; } } } $querystring_naked = preg_replace( '~[&]+~', '&', $querystring_naked ); $querystring_naked = trim( $querystring_naked, '&' ); return empty( $querystring_naked ) ? '' : '?' . $querystring_naked; } /** * Handles delayed cache printing when applicable. * * @return void */ public function delayed_cache_print() { if ( $this->_late_caching && $this->_caching ) { $this->_cached_data = $this->_extract_cached_page( true ); if ( $this->_cached_data ) { global $w3tc_w3_late_caching_succeeded; $w3tc_w3_late_caching_succeeded = true; $this->process_status = 'hit'; $this->process_cached_page_and_exit( $this->_cached_data ); // if is passes here - exit is not possible now and will happen on init. return; } } if ( $this->_late_init && $this->_caching ) { $this->process_status = 'hit'; $this->process_cached_page_and_exit( $this->_cached_data ); // if is passes here - exit is not possible now and will happen on init. return; } } /** * Conditionally saves the cached result with optional compression. * * @param string $buffer The content buffer to cache. * @param array $response_headers The response headers to include in the cache. * @param bool $has_dynamic Whether the content includes dynamic tags. * * @return string The buffer, possibly modified for compression. */ private function _maybe_save_cached_result( $buffer, $response_headers, $has_dynamic ) { $mobile_group = $this->_page_key_extension['useragent']; $referrer_group = $this->_page_key_extension['referrer']; $encryption = $this->_page_key_extension['encryption']; $compression_header = $this->_page_key_extension['compression']; $compressions_to_store = $this->_get_compressions(); // Don't compress here for debug mode or dynamic tags because we need to modify buffer before send it to client. if ( $this->_debug || $has_dynamic ) { $compressions_to_store = array( false ); } // Right now dont return compressed buffer if we are dynamic that will happen on shutdown after processing dynamic stuff. $compression_of_returned_content = ( $has_dynamic ? false : $compression_header ); $headers = $this->_get_cached_headers( $response_headers['plain'] ); if ( ! empty( $headers['Status-Code'] ) ) { $is_404 = ( '404' === $headers['Status-Code'] ); } elseif ( function_exists( 'is_404' ) ) { $is_404 = is_404(); } else { $is_404 = false; } if ( $this->_enhanced_mode ) { // Redirect issued, if we have some old cache entries they will be turned into fresh files and catch further requests. if ( isset( $response_headers['kv']['location'] ) ) { $cache = $this->_get_cache( $this->_page_key_extension['group'] ); foreach ( $compressions_to_store as $_compression ) { $_page_key = $this->_get_page_key( array_merge( $this->_page_key_extension, array( 'compression' => $_compression, ) ) ); $cache->hard_delete( $_page_key ); } return $buffer; } } $content_type = ''; if ( $this->_enhanced_mode && ! $this->_late_init ) { register_shutdown_function( array( $this, '_check_rules_present', ) ); if ( isset( $response_headers['kv']['content-type'] ) ) { $content_type = $response_headers['kv']['content-type']; } } $time = time(); $cache = $this->_get_cache( $this->_page_key_extension['group'] ); // Store different versions of cache. $buffers = array(); $something_was_set = false; do_action( 'w3tc_pagecache_before_set', array( 'request_url_fragments' => $this->_request_url_fragments, 'page_key_extension' => $this->_page_key_extension, ) ); foreach ( $compressions_to_store as $_compression ) { $this->_set_extract_page_key( array_merge( $this->_page_key_extension, array( 'compression' => $_compression, 'content_type' => $content_type, ) ), true ); if ( empty( $this->_page_key ) ) { continue; } // Compress content. $buffers[ $_compression ] = $this->_compress( $buffer, $_compression ); // Store cache data. $_data = array( '404' => $is_404, 'headers' => $headers, 'time' => $time, 'content' => $buffers[ $_compression ], ); if ( ! empty( $_compression ) ) { $_data['c'] = $_compression; } if ( $has_dynamic ) { $_data['has_dynamic'] = true; } $_data = apply_filters( 'w3tc_pagecache_set', $_data, $this->_page_key, $this->_page_group ); if ( ! empty( $_data ) ) { $cache->set( $this->_page_key, $_data, $this->_lifetime, $this->_page_group ); $something_was_set = true; } } if ( $something_was_set ) { $this->process_status = 'miss_fill'; } else { $this->process_status = 'miss_third_party'; } // Change buffer if using compression. if ( defined( 'W3TC_PAGECACHE_OUTPUT_COMPRESSION_OFF' ) ) { $compression_header = false; } elseif ( $compression_of_returned_content && isset( $buffers[ $compression_of_returned_content ] ) ) { $buffer = $buffers[ $compression_of_returned_content ]; } // Calculate content etag. $etag = md5( $buffer ); // Send headers. $this->_send_headers( $is_404, $time, $etag, $compression_header, $headers ); return $buffer; } /** * Collects usage statistics for the current request. * * @param object $storage The storage object for saving statistics. * * @return void */ public function w3tc_usage_statistics_of_request( $storage ) { global $w3tc_start_microtime; $time_ms = 0; if ( ! empty( $w3tc_start_microtime ) ) { $time_ms = (int) ( ( microtime( true ) - $w3tc_start_microtime ) * 1000 ); $storage->counter_add( 'pagecache_requests_time_10ms', (int) ( $time_ms / 10 ) ); } if ( ! empty( $this->process_status ) ) { // see registered keys in PgCache_Plugin.w3tc_usage_statistics_metrics. $storage->counter_add( 'php_requests_pagecache_' . $this->process_status, 1 ); if ( $this->_debug ) { self::log( 'finished in ' . $time_ms . ' size ' . $this->output_size . ' with process status ' . $this->process_status . ' reason ' . $this->cache_reject_reason ); } } } /** * Logs a message to the page cache log. * * @param string $msg The message to log. * * @return bool|int The number of bytes written, or false on failure. */ protected static function log( $msg ) { $w3tc_data = sprintf( '[%1$s] [%2$s] [%3$s] %4$s ' . "\n", gmdate( 'r' ), isset( $_SERVER['REQUEST_URI'] ) ? filter_var( stripslashes( $_SERVER['REQUEST_URI'] ), FILTER_SANITIZE_URL ) : '', ! empty( $_SERVER['HTTP_REFERER'] ) ? htmlspecialchars( $_SERVER['HTTP_REFERER'] ) : '-', $msg ); $w3tc_data = strtr( $w3tc_data, '<>', '..' ); /** * Assignment used to land in `$date` (typo for `$w3tc_data`), * so the redacted form was never written and the log file kept * the raw `_wpnonce=` value. Keep the variable name on `$w3tc_data` * so the file_put_contents below writes the redacted form. * Full `redact()` covers nonces plus remaining assignment forms * (query secrets, JSON, cookies) that appear on REQUEST_URI. */ $w3tc_data = Util_Debug::redact( $w3tc_data ); $filename = Util_Debug::log_filename( 'pagecache' ); return @file_put_contents( $filename, $w3tc_data, FILE_APPEND ); } }