post_status ) { return self::remember( 'status:' . $post->post_status ); } if ( ! empty( $post->post_password ) || post_password_required( $post ) ) { return self::remember( 'password-protected' ); } } } // A response that sets a cookie is establishing per-visitor state, so the // body almost certainly depends on it. headers_list() catches raw header() // calls that never touch $_COOKIE. if ( self::response_sets_cookie() ) { return self::remember( 'sets-cookie' ); } $scope_reason = self::scope_bypass_reason(); if ( null !== $scope_reason ) { return self::remember( $scope_reason ); } $host_reason = self::host_bypass_reason(); if ( null !== $host_reason ) { return self::remember( $host_reason ); } // Logged-in editors previewing the frontend are excluded from every other // Performance Suite feature (see DelayJs); stay consistent. This is // unreachable while the is_user_logged_in() check above stands, but both // are cheap and the intent should survive future edits to either. if ( is_user_logged_in() && current_user_can( 'edit_posts' ) && (bool) apply_filters( 'ablocks/perf/bypass_optimizations_for_editors', true ) ) { return self::remember( 'editor' ); } return self::remember( null ); } /** * Is the response body itself cacheable? * * Guards against freezing a truncated page — a fatal error, an uncaught * exception or a bare exit() mid-render produces output that looks fine to * every check above but is missing its closing tags. Caching that would serve * a broken page to everyone until the next purge, which is the single worst * failure mode this feature has. * * @param string $html Buffered output. * @return string|null Bypass reason, or null when the body is cacheable. */ public static function body_bypass_reason( $html ) { if ( strlen( $html ) < 255 ) { return self::remember( 'body-too-short' ); } if ( false === stripos( $html, '' ) ) { return self::remember( 'body-truncated' ); } return self::remember( null ); } /** * The last bypass reason recorded, for debug headers and CLI output. * * @return string|null */ public static function last_reason() { return self::$last_reason; } /** * Is the page cache switched on? */ public static function is_enabled() { $enabled = (bool) Helper::get_settings( 'perf_page_cache', false ); return (bool) apply_filters( 'ablocks/perf/perf_page_cache', $enabled ); } /** * Cookie prefixes that force a bypass. Filterable so a site can add its own * personalisation cookie (membership plugins, geo redirectors, A/B tools). * * @return string[] */ public static function bypass_cookie_prefixes() { return (array) apply_filters( 'ablocks/perf/page_cache/bypass_cookies', self::BYPASS_COOKIE_PREFIXES ); } /** * Query args that may appear without disabling the cache. * * Deliberately empty by default: any unrecognised query string bypasses. The * alternative — ignoring unknown args — lets ?utm_source=x overwrite the * canonical entry for a URL, which is cache poisoning by typo. * * @return string[] */ public static function allowed_query_args() { $allowed = (array) Helper::get_settings( 'perf_page_cache_query_args', [] ); return array_filter( array_map( 'strval', (array) apply_filters( 'ablocks/perf/page_cache/allowed_query_args', $allowed ) ) ); } /** * Is the request's Host header one this site actually answers to? * * HTTP_HOST is attacker-controlled. Store::sanitize_host() already guarantees * containment — a spoofed `Host: ../../evil` cannot escape the cache * directory — but containment alone still lets an attacker create an * unbounded number of junk directories by varying the header, which is a * slow disk-fill. It also has no legitimate use: a request for a host we do * not serve should not populate the cache. * * Checked at write time only. Writing is the sole operation that creates * directories, so gating it here closes the vector without adding a database * read to the serve path (which must stay callable before WordPress loads). * A serve-time request for an unknown host simply finds no file. * * @return string|null */ private static function host_bypass_reason() { $request_host = isset( $_SERVER['HTTP_HOST'] ) ? wp_unslash( $_SERVER['HTTP_HOST'] ) : ''; $request_host = self::normalize_host( is_string( $request_host ) ? $request_host : '' ); if ( '' === $request_host ) { return 'host-missing'; } $allowed = []; foreach ( [ home_url(), site_url() ] as $known ) { $parts = wp_parse_url( $known ); if ( ! empty( $parts['host'] ) ) { $allowed[] = self::normalize_host( $parts['host'] ); } } // Sites behind a proxy, CDN or domain alias legitimately serve more than // one host; they add theirs here rather than losing the cache entirely. $allowed = array_filter( array_map( [ __CLASS__, 'normalize_host' ], (array) apply_filters( 'ablocks/perf/page_cache/allowed_hosts', $allowed ) ) ); if ( ! in_array( $request_host, $allowed, true ) ) { return 'host-mismatch'; } return null; } /** * Lowercase a host and drop any port, for comparison purposes. * * The port is deliberately kept in the *directory* name by Store, so that a * :8080 dev site cannot collide with production; it is only stripped here, * where the question is which site the request is for. * * @param string $host Raw host, possibly with a port. * @return string */ public static function normalize_host( $host ) { $host = strtolower( trim( (string) $host ) ); $host = preg_replace( '/:\d+$/', '', $host ); return (string) $host; } /** * Does the configured coverage scope exclude this page? * * Default scope is `all`, which is what anyone flipping a switch labelled * "Page Cache" expects. `ablocks_only` is the conservative mode: cache just * the pages this plugin actually renders, so the blast radius is limited to * content aBlocks is responsible for. * * Only singular content can be judged this way — archives and the front page * assemble many posts plus template parts, so a content scan there would be * both expensive and wrong. Those are cached under either scope. * * @return string|null */ private static function scope_bypass_reason() { $scope = (string) Helper::get_settings( 'perf_page_cache_scope', 'all' ); $scope = (string) apply_filters( 'ablocks/perf/page_cache/scope', $scope ); if ( 'ablocks_only' !== $scope || ! is_singular() ) { return null; } $post = get_post(); if ( ! $post instanceof \WP_Post ) { return null; } // `wp:block` counts: a reusable block may wrap aBlocks blocks, and the // same allowance is made in Blocks::prewarm_page_assets(). $content = (string) $post->post_content; if ( false === strpos( $content, 'wp:ablocks' ) && false === strpos( $content, 'wp:block' ) ) { return 'scope:no-ablocks-blocks'; } return null; } /** * Does a bypass cookie exist on this request? * * @return string|null */ private static function bypass_cookie_reason() { if ( empty( $_COOKIE ) || ! is_array( $_COOKIE ) ) { return null; } $prefixes = self::bypass_cookie_prefixes(); foreach ( array_keys( $_COOKIE ) as $name ) { $name = (string) $name; foreach ( $prefixes as $prefix ) { if ( 0 === strpos( $name, $prefix ) ) { // The reason names the prefix, never the cookie value. return 'cookie:' . $prefix; } } } return null; } /** * Does the query string disqualify this request? * * @return string|null */ private static function query_bypass_reason() { // Only argument *names* are inspected, never values, and nothing is acted // on beyond declining to cache — so there is no input to sanitize and a // nonce would be meaningless on an anonymous cacheable request. // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Reads query-arg names only, to decide cacheability. if ( empty( $_GET ) || ! is_array( $_GET ) ) { return null; } $allowed = self::allowed_query_args(); foreach ( array_keys( $_GET ) as $arg ) { if ( ! in_array( (string) $arg, $allowed, true ) ) { return 'query-arg'; } } // phpcs:enable WordPress.Security.NonceVerification.Recommended return null; } /** * Does the request path match a user-configured exclusion pattern? * * Patterns are simple wildcards (`/shop/*`), not regular expressions — a * malformed regex in a settings field would otherwise take the site down. */ private static function is_excluded_url() { $patterns = (array) Helper::get_settings( 'perf_page_cache_exclusions', [] ); $patterns = (array) apply_filters( 'ablocks/perf/page_cache/exclusions', $patterns ); if ( empty( $patterns ) ) { return false; } $path = self::request_path(); foreach ( $patterns as $pattern ) { $pattern = trim( (string) $pattern ); if ( '' === $pattern ) { continue; } if ( fnmatch( $pattern, $path ) ) { return true; } } return false; } /** * The current request path, without query string, always leading-slashed. * * @return string */ public static function request_path() { $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '/'; $uri = is_string( $uri ) ? $uri : '/'; $path = (string) strtok( $uri, '?' ); if ( '' === $path || '/' !== $path[0] ) { $path = '/' . $path; } return $path; } /** * Final HTTP status for this response. * * @return int */ private static function response_status() { $status = function_exists( 'http_response_code' ) ? http_response_code() : 200; return is_int( $status ) ? $status : 200; } /** * Has anything queued a Set-Cookie header on this response? */ private static function response_sets_cookie() { if ( ! function_exists( 'headers_list' ) ) { return false; } foreach ( headers_list() as $header ) { if ( 0 === stripos( $header, 'set-cookie:' ) ) { return true; } } return false; } /** * Record and return a reason, so callers can chain `return self::remember(...)`. * * @param string|null $reason Bypass reason or null. * @return string|null */ private static function remember( $reason ) { self::$last_reason = $reason; return $reason; } }