| @@ -31,14 +31,28 @@ | ||
| 31 | 31 | if ( ! function_exists( 'insert_with_markers' ) ) { |
| 32 | 32 | require_once ABSPATH . 'wp-admin/includes/misc.php'; |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | - $rules = $enabled ? self::apache_rules() : array(); | |
| 35 | + // Build the block from the GZIP base (only when GZIP is on) plus any | |
| 36 | + // rules add-ons append via the filter. An add-on (e.g. the Pro Brotli | |
| 37 | + // module) may want its own directives even when GZIP itself is off — | |
| 38 | + // so we run the filter regardless and write whatever it returns, | |
| 39 | + // emptying the marker block only when there is genuinely nothing. | |
| 40 | + $rules = self::apache_rules( (bool) $enabled ); | |
| 36 | 41 | return (bool) insert_with_markers( $htaccess, self::MARKER, $rules ); |
| 37 | 42 | } |
| 38 | 43 | |
| 39 | - public static function apache_rules() { | |
| 40 | - return array( | |
| 44 | + /** | |
| 45 | + * Compression rules written inside the marker block. | |
| 46 | + * | |
| 47 | + * @param bool $gzip_enabled Whether to include the GZIP (mod_deflate) | |
| 48 | + * base rules. Add-on filter contributions are | |
| 49 | + * applied either way, so Brotli (or another | |
| 50 | + * encoder) can be served even with GZIP off. | |
| 51 | + * @return string[] | |
| 52 | + */ | |
| 53 | + public static function apache_rules( $gzip_enabled = true ) { | |
| 54 | + $rules = $gzip_enabled ? array( | |
| 41 | 55 | '<IfModule mod_deflate.c>', |
| 42 | 56 | ' AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript', |
| 43 | 57 | ' AddOutputFilterByType DEFLATE application/javascript application/x-javascript application/json', |
| 44 | 58 | ' AddOutputFilterByType DEFLATE application/xml application/xhtml+xml application/rss+xml', |
| @@ -43,9 +57,24 @@ | ||
| 43 | 57 | ' AddOutputFilterByType DEFLATE application/javascript application/x-javascript application/json', |
| 44 | 58 | ' AddOutputFilterByType DEFLATE application/xml application/xhtml+xml application/rss+xml', |
| 45 | 59 | ' AddOutputFilterByType DEFLATE image/svg+xml font/ttf font/otf application/font-woff application/font-woff2', |
| 46 | 60 | '</IfModule>', |
| 47 | - ); | |
| 61 | + ) : array(); | |
| 62 | + | |
| 63 | + /** | |
| 64 | + * Filter the Apache compression rules written to .htaccess. | |
| 65 | + * | |
| 66 | + * The core engine emits GZIP (mod_deflate) when GZIP is enabled. | |
| 67 | + * Add-ons (xspeed-pro Brotli module) append their own | |
| 68 | + * <IfModule mod_brotli.c> block so Brotli is served where supported, | |
| 69 | + * GZIP otherwise. The base array is empty when GZIP is off, so an | |
| 70 | + * add-on can be the sole contributor. Listeners MUST return the full | |
| 71 | + * rules array (append, don't replace). | |
| 72 | + * | |
| 73 | + * @param string[] $rules Lines written inside the xSpeed GZIP marker block. | |
| 74 | + * @param bool $gzip_enabled Whether GZIP's own base rules are included. | |
| 75 | + */ | |
| 76 | + return (array) apply_filters( 'xspeed_compression_apache_rules', $rules, $gzip_enabled ); | |
| 48 | 77 | } |
| 49 | 78 | |
| 50 | 79 | /** |
| 51 | 80 | * nginx config snippet for users to paste into their server block. |
| @@ -50,9 +79,9 @@ | ||
| 50 | 79 | /** |
| 51 | 80 | * nginx config snippet for users to paste into their server block. |
| 52 | 81 | */ |
| 53 | 82 | public static function nginx_snippet() { |
| 54 | - return implode( | |
| 83 | + $snippet = implode( | |
| 55 | 84 | "\n", |
| 56 | 85 | array( |
| 57 | 86 | 'gzip on;', |
| 58 | 87 | 'gzip_vary on;', |
| @@ -65,17 +94,48 @@ | ||
| 65 | 94 | ' application/xml+rss application/xhtml+xml', |
| 66 | 95 | ' image/svg+xml font/ttf font/otf application/font-woff application/font-woff2;', |
| 67 | 96 | ) |
| 68 | 97 | ); |
| 98 | + | |
| 99 | + /** | |
| 100 | + * Filter the nginx compression snippet shown for manual config. | |
| 101 | + * | |
| 102 | + * Core emits the GZIP directives. Add-ons (xspeed-pro Brotli | |
| 103 | + * module) append an ngx_brotli block so users can paste Brotli + | |
| 104 | + * GZIP fallback in one go. Return the full snippet string. | |
| 105 | + * | |
| 106 | + * @param string $snippet The nginx directives block. | |
| 107 | + */ | |
| 108 | + return (string) apply_filters( 'xspeed_compression_nginx_snippet', $snippet ); | |
| 69 | 109 | } |
| 70 | 110 | |
| 71 | 111 | /** |
| 72 | - * Returns true if the response we just received looks GZIP-encoded. | |
| 73 | - * Result is cached in a transient for 1 hour because the probe makes a | |
| 74 | - * full HTTP request to home_url() — too slow to run on every /status hit. | |
| 112 | + * Does the origin actually serve a GZIP-encoded homepage? | |
| 113 | + * | |
| 114 | + * true — proven: the response was gzipped. | |
| 115 | + * false — proven otherwise: we reached the site and it wasn't. | |
| 116 | + * null — no verdict: the loopback never completed (firewalled, | |
| 117 | + * TLS failure, timeout, WAF answering instead of the origin). | |
| 118 | + * | |
| 119 | + * The third state is the point (issue #18). The old signature folded | |
| 120 | + * "couldn't ask" into "answer is no" and cached that for an hour, which | |
| 121 | + * pinned a permanent "GZIP enabled but not active on the server" warning | |
| 122 | + * onto sites whose gzip was demonstrably fine — the loopback was the | |
| 123 | + * only broken thing. Only a *proven* false may nag the user. Mirrors the | |
| 124 | + * inconclusive/active split already used by the rewrite probe. | |
| 125 | + * | |
| 126 | + * A verdict is cached for an hour because the probe costs a full HTTP | |
| 127 | + * request to home_url() — too slow to run on every /status hit. A | |
| 128 | + * non-verdict is cached for a minute only, so a transient blip resolves | |
| 129 | + * itself on the next page load instead of sticking around. | |
| 130 | + * | |
| 131 | + * @return bool|null | |
| 75 | 132 | */ |
| 76 | 133 | public static function probe_active() { |
| 77 | 134 | $cached = get_transient( 'xspeed_gzip_active' ); |
| 135 | + if ( '?' === $cached ) { | |
| 136 | + return null; | |
| 137 | + } | |
| 78 | 138 | if ( false !== $cached ) { |
| 79 | 139 | return '1' === $cached; |
| 80 | 140 | } |
| 81 | 141 | |
| @@ -81,17 +141,43 @@ | ||
| 81 | 141 | |
| 82 | 142 | $res = wp_remote_get( |
| 83 | 143 | home_url( '/' ), |
| 84 | 144 | array( |
| 85 | - 'headers' => array( 'Accept-Encoding' => 'gzip' ), | |
| 86 | - 'timeout' => 3, | |
| 145 | + 'headers' => array( 'Accept-Encoding' => 'gzip' ), | |
| 146 | + // 3s wasn't enough to pull a full homepage on a busy shared | |
| 147 | + // host, and every timeout used to read as "gzip is broken". | |
| 148 | + 'timeout' => 5, | |
| 149 | + // Loopback to our own hostname; staging boxes routinely have | |
| 150 | + // a self-signed or mismatched cert. Same call the sibling | |
| 151 | + // probes make (Browser_Cache::probe_headers_present()). | |
| 152 | + 'sslverify' => false, | |
| 153 | + // Keep the payload exactly as it came off the wire, so the | |
| 154 | + // gzip magic-byte fallback below still has something to | |
| 155 | + // look at if a transport strips Content-Encoding after | |
| 156 | + // decoding. See Cache_Benchmark::measure(). | |
| 157 | + 'decompress' => false, | |
| 87 | 158 | ) |
| 88 | 159 | ); |
| 89 | 160 | |
| 90 | - $active = false; | |
| 91 | - if ( ! is_wp_error( $res ) ) { | |
| 92 | - $enc = wp_remote_retrieve_header( $res, 'content-encoding' ); | |
| 93 | - $active = is_string( $enc ) && false !== stripos( $enc, 'gzip' ); | |
| 161 | + if ( is_wp_error( $res ) || 200 !== (int) wp_remote_retrieve_response_code( $res ) ) { | |
| 162 | + set_transient( 'xspeed_gzip_active', '?', MINUTE_IN_SECONDS ); | |
| 163 | + return null; | |
| 164 | + } | |
| 165 | + | |
| 166 | + // wp_remote_retrieve_header() hands back a STRING for a single | |
| 167 | + // header but an ARRAY when it appears twice — routine behind a | |
| 168 | + // CDN or proxy. The old is_string() test threw those away and | |
| 169 | + // reported "not gzipped". (Same trap as FBS-82141.) | |
| 170 | + $raw = wp_remote_retrieve_header( $res, 'content-encoding' ); | |
| 171 | + $enc = is_array( $raw ) ? implode( ', ', $raw ) : (string) $raw; | |
| 172 | + | |
| 173 | + $active = false !== stripos( $enc, 'gzip' ); | |
| 174 | + if ( ! $active ) { | |
| 175 | + // Header absent — a transport that decodes and drops it would | |
| 176 | + // look identical to a server that never compressed. The raw | |
| 177 | + // body settles it: a gzip stream starts with 0x1f 0x8b. | |
| 178 | + $body = wp_remote_retrieve_body( $res ); | |
| 179 | + $active = is_string( $body ) && 0 === strncmp( $body, "\x1f\x8b", 2 ); | |
| 94 | 180 | } |
| 95 | 181 | |
| 96 | 182 | set_transient( 'xspeed_gzip_active', $active ? '1' : '0', HOUR_IN_SECONDS ); |
| 97 | 183 | return $active; |