| @@ -11,8 +11,10 @@ | ||
| 11 | 11 | declare(strict_types=1); |
| 12 | 12 | |
| 13 | 13 | namespace XSpeed\Modules\Cdn; |
| 14 | 14 | |
| 15 | +defined( 'ABSPATH' ) || exit; | |
| 16 | + | |
| 15 | 17 | use XSpeed\Cdn_Rewriter; |
| 16 | 18 | use XSpeed\Module; |
| 17 | 19 | |
| 18 | 20 | final class CdnModule extends Module { |
| @@ -22,11 +24,11 @@ | ||
| 22 | 24 | public const VERSION = '1.0.0'; |
| 23 | 25 | |
| 24 | 26 | public function ui_metadata(): array { |
| 25 | 27 | return array( |
| 26 | - 'label' => 'CDN', | |
| 28 | + 'label' => __( 'CDN', 'xspeed' ), | |
| 27 | 29 | 'icon' => 'Globe', |
| 28 | - 'description' => 'Serve static assets (images, fonts, CSS, JS) from a pull-zone CDN host like BunnyCDN, KeyCDN, or your own.', | |
| 30 | + 'description' => __( 'Serve static assets (images, fonts, CSS, JS) from a pull-zone CDN host like BunnyCDN, KeyCDN, or your own.', 'xspeed' ), | |
| 29 | 31 | ); |
| 30 | 32 | } |
| 31 | 33 | |
| 32 | 34 | public function settings_schema(): array { |
| @@ -33,30 +35,33 @@ | ||
| 33 | 35 | return array( |
| 34 | 36 | 'enabled' => array( |
| 35 | 37 | 'type' => 'bool', |
| 36 | 38 | 'default' => false, |
| 37 | - 'label' => 'Enable CDN', | |
| 38 | - 'description' => 'Rewrite static asset URLs to the CDN hostname below. Your CDN must be a pull-zone configured to fetch from this site.', | |
| 39 | + 'label' => __( 'Enable CDN', 'xspeed' ), | |
| 40 | + 'description' => __( 'Rewrite static asset URLs to the CDN hostname below. Your CDN must be a pull-zone configured to fetch from this site.', 'xspeed' ), | |
| 39 | 41 | ), |
| 40 | 42 | 'cdn_url' => array( |
| 41 | 43 | 'type' => 'string', |
| 42 | 44 | 'default' => '', |
| 43 | - 'label' => 'CDN URL', | |
| 44 | - 'description' => 'CDN hostname, e.g. cdn.example.com. https:// and trailing slashes are stripped automatically.', | |
| 45 | + 'label' => __( 'CDN URL', 'xspeed' ), | |
| 46 | + 'description' => __( 'CDN hostname, e.g. cdn.example.com. https:// and trailing slashes are stripped automatically.', 'xspeed' ), | |
| 47 | + 'dependsOn' => array( 'field' => 'enabled' ), | |
| 45 | 48 | ), |
| 46 | 49 | 'included_extensions' => array( |
| 47 | 50 | 'type' => 'list', |
| 48 | 51 | 'default' => Cdn_Rewriter::DEFAULT_EXTENSIONS, |
| 49 | 52 | 'item_type' => 'string', |
| 50 | - 'label' => 'Included File Extensions', | |
| 51 | - 'description' => 'Only URLs ending in these extensions are rewritten. Defaults cover images, fonts, CSS, JS, and common media.', | |
| 53 | + 'label' => __( 'Included File Extensions', 'xspeed' ), | |
| 54 | + 'description' => __( 'Only URLs ending in these extensions are rewritten. Defaults cover images, fonts, CSS, JS, and common media.', 'xspeed' ), | |
| 55 | + 'dependsOn' => array( 'field' => 'enabled' ), | |
| 52 | 56 | ), |
| 53 | 57 | 'excluded_patterns' => array( |
| 54 | 58 | 'type' => 'list', |
| 55 | 59 | 'default' => array(), |
| 56 | 60 | 'item_type' => 'string', |
| 57 | - 'label' => 'Excluded Patterns', | |
| 58 | - 'description' => 'Glob patterns matched against the URL path. Matching URLs stay on the origin. Examples: /wp-admin/*, *.pdf, /private/*', | |
| 61 | + 'label' => __( 'Excluded Patterns', 'xspeed' ), | |
| 62 | + 'description' => __( 'Glob patterns matched against the URL path. Matching URLs stay on the origin. Examples: /wp-admin/*, *.pdf, /private/*', 'xspeed' ), | |
| 63 | + 'dependsOn' => array( 'field' => 'enabled' ), | |
| 59 | 64 | ), |
| 60 | 65 | ); |
| 61 | 66 | } |
| 62 | 67 | |
| @@ -71,27 +76,316 @@ | ||
| 71 | 76 | ); |
| 72 | 77 | } |
| 73 | 78 | |
| 74 | 79 | public function boot(): void { |
| 80 | + /* | |
| 81 | + * Deferred to `init`. This module reads its own settings to decide | |
| 82 | + * what to hook, and reading settings builds settings_schema(), whose | |
| 83 | + * labels are declared through __(). boot() runs on `plugins_loaded`, | |
| 84 | + * before `after_setup_theme` — the point WordPress 6.7+ treats as the | |
| 85 | + * earliest safe moment to translate — so doing that here fires | |
| 86 | + * _load_textdomain_just_in_time on every request AND resolves the | |
| 87 | + * labels against a domain that is not loaded yet. | |
| 88 | + * | |
| 89 | + * Everything below hooks actions that fire after `init`, so running | |
| 90 | + * one hook later is equivalent. | |
| 91 | + */ | |
| 92 | + add_action( 'init', array( $this, 'boot_on_init' ) ); | |
| 93 | + } | |
| 94 | + | |
| 95 | + /** | |
| 96 | + * The real boot body — see boot() for why it runs on `init`. | |
| 97 | + */ | |
| 98 | + public function boot_on_init(): void { | |
| 75 | 99 | // Always-on: normalize cdn_url on save (admin context too). |
| 76 | 100 | add_filter( 'pre_update_option_xspeed_module_cdn', array( $this, 'normalize_on_save' ), 10, 1 ); |
| 77 | 101 | |
| 102 | + // CDN URLs are baked into cached HTML, so a settings change that | |
| 103 | + // isn't followed by a purge is invisible: the user edits the CDN | |
| 104 | + // host, reloads, sees the old host still served from cache, and | |
| 105 | + // concludes the feature is broken. Also keeps the font-CORS rules | |
| 106 | + // in .htaccess in step with the enabled flag. | |
| 107 | + add_action( 'update_option_xspeed_module_cdn', array( $this, 'on_settings_change' ), 10, 0 ); | |
| 108 | + | |
| 78 | 109 | if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { |
| 79 | 110 | return; |
| 80 | 111 | } |
| 112 | + | |
| 113 | + // Rewriting asset hosts under a builder editor sends the editor's own | |
| 114 | + // scripts to the CDN, where the copy can be stale or absent. (#281) | |
| 115 | + if ( \XSpeed\Builder_Editor::is_active() ) { | |
| 116 | + return; | |
| 117 | + } | |
| 81 | 118 | $opts = $this->get_settings(); |
| 82 | 119 | if ( empty( $opts['enabled'] ) || empty( $opts['cdn_url'] ) ) { |
| 83 | 120 | return; |
| 84 | 121 | } |
| 85 | 122 | Cdn_Rewriter::reset_state(); |
| 86 | - // Late filter — same convention as Lazy_Loader. Runs after | |
| 87 | - // shortcodes / blocks / embeds finish injecting tags. | |
| 88 | - add_filter( 'the_content', array( Cdn_Rewriter::class, 'process_html' ), 1000 ); | |
| 89 | - add_filter( 'post_thumbnail_html', array( Cdn_Rewriter::class, 'process_html' ), 1000 ); | |
| 90 | - add_filter( 'widget_text_content', array( Cdn_Rewriter::class, 'process_html' ), 1000 ); | |
| 123 | + | |
| 124 | + // Attachment URLs still go through their own filter: media-library | |
| 125 | + // URLs are frequently consumed as PHP strings (feeds, oEmbed, REST | |
| 126 | + // echoes) rather than emitted into the page HTML we rewrite below. | |
| 91 | 127 | add_filter( 'wp_get_attachment_url', array( $this, 'rewrite_attachment_url' ), 1000 ); |
| 128 | + | |
| 129 | + // Preconnect to the CDN host. Every asset on the page now resolves | |
| 130 | + // there, so paying the DNS + TLS handshake once up front rather than | |
| 131 | + // on first asset request is worth the one tag. | |
| 132 | + add_filter( 'wp_resource_hints', array( $this, 'add_preconnect' ), 10, 2 ); | |
| 133 | + | |
| 134 | + // Whole-page pass. | |
| 135 | + // | |
| 136 | + // This module used to hook only the_content, post_thumbnail_html and | |
| 137 | + // widget_text_content — four filters that between them can never | |
| 138 | + // contain a stylesheet, a script or a font. So `css`, `js` and the | |
| 139 | + // five font extensions shipped ticked by default and rewrote nothing: | |
| 140 | + // a user enabled the CDN, saw them enabled, and found zero requests | |
| 141 | + // in their pull zone. | |
| 142 | + // | |
| 143 | + // Enqueued assets can't be reached with those filters at all, and | |
| 144 | + // hooking style_loader_src/script_loader_src would still miss inline | |
| 145 | + // url(), hardcoded theme-template images and third-party echo output. | |
| 146 | + // One pass over the finished page catches every category at once. | |
| 147 | + // | |
| 148 | + // It also fixes the srcset split: core builds srcset from | |
| 149 | + // wp_get_upload_dir() and never calls wp_get_attachment_url(), so a | |
| 150 | + // theme image previously got a CDN `src` and an origin `srcset` in | |
| 151 | + // the same tag. | |
| 152 | + // | |
| 153 | + // Cost: on the cache-write path this runs once per MISS and the CDN | |
| 154 | + // URLs bake into the stored HTML, so cache HITs pay nothing. This is | |
| 155 | + // what Powered Cache, Breeze and SpeedyCache all do. The trade-off is | |
| 156 | + // that turning the CDN off needs a cache purge — handled by | |
| 157 | + // purge_on_change() below. | |
| 158 | + add_filter( | |
| 159 | + 'xspeed_cache_final_html', | |
| 160 | + static function ( $html ) { | |
| 161 | + if ( ! self::should_rewrite_request() ) { | |
| 162 | + return $html; | |
| 163 | + } | |
| 164 | + return Cdn_Rewriter::process_html( (string) $html ); | |
| 165 | + }, | |
| 166 | + // After Resource Hints (10) so any preload/preconnect tag it | |
| 167 | + // injects gets its URL rewritten too. | |
| 168 | + 20, | |
| 169 | + 1 | |
| 170 | + ); | |
| 171 | + | |
| 172 | + // Cache-off path: the filter above never fires, so buffer the page | |
| 173 | + // ourselves. Guarded so we never double-buffer when the cache engine | |
| 174 | + // is running. | |
| 175 | + if ( ! $this->cache_enabled() ) { | |
| 176 | + add_action( | |
| 177 | + 'template_redirect', | |
| 178 | + static function () { | |
| 179 | + if ( self::$buffering || ! self::should_rewrite_request() ) { | |
| 180 | + return; | |
| 181 | + } | |
| 182 | + self::$buffering = true; | |
| 183 | + ob_start( | |
| 184 | + static function ( $buffer ) { | |
| 185 | + if ( strlen( (string) $buffer ) < 255 ) { | |
| 186 | + return $buffer; | |
| 187 | + } | |
| 188 | + return Cdn_Rewriter::process_html( (string) $buffer ); | |
| 189 | + } | |
| 190 | + ); | |
| 191 | + }, | |
| 192 | + 9 | |
| 193 | + ); | |
| 194 | + } | |
| 92 | 195 | } |
| 93 | 196 | |
| 197 | + /** | |
| 198 | + * Guard against opening our buffer twice on one request. | |
| 199 | + * | |
| 200 | + * @var bool | |
| 201 | + */ | |
| 202 | + private static $buffering = false; | |
| 203 | + | |
| 204 | + /** | |
| 205 | + * Should this request have its asset URLs rewritten at all? | |
| 206 | + * | |
| 207 | + * The module's original bail set covered admin / AJAX / cron / REST only. | |
| 208 | + * These four are the remaining request types where a CDN URL is either | |
| 209 | + * wrong or actively unhelpful: | |
| 210 | + * | |
| 211 | + * - Previews render unsaved content for one logged-in author; pointing | |
| 212 | + * their assets at a pull zone caches a draft at the edge. | |
| 213 | + * - robots.txt and trackbacks are not HTML and have no assets. | |
| 214 | + * - Non-GET requests are form posts and API calls, never a page whose | |
| 215 | + * asset URLs matter. | |
| 216 | + */ | |
| 217 | + public static function should_rewrite_request(): bool { | |
| 218 | + $method = isset( $_SERVER['REQUEST_METHOD'] ) | |
| 219 | + ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) | |
| 220 | + : 'GET'; | |
| 221 | + if ( 'GET' !== $method && 'HEAD' !== $method ) { | |
| 222 | + return false; | |
| 223 | + } | |
| 224 | + if ( function_exists( 'is_preview' ) && is_preview() ) { | |
| 225 | + return false; | |
| 226 | + } | |
| 227 | + if ( function_exists( 'is_robots' ) && is_robots() ) { | |
| 228 | + return false; | |
| 229 | + } | |
| 230 | + if ( function_exists( 'is_trackback' ) && is_trackback() ) { | |
| 231 | + return false; | |
| 232 | + } | |
| 233 | + if ( function_exists( 'is_feed' ) && is_feed() ) { | |
| 234 | + return false; | |
| 235 | + } | |
| 236 | + | |
| 237 | + /** | |
| 238 | + * Final say on whether to rewrite asset URLs for this request. | |
| 239 | + * | |
| 240 | + * @param bool $should Whether to rewrite. | |
| 241 | + */ | |
| 242 | + return (bool) apply_filters( 'xspeed_cdn_should_rewrite', true ); | |
| 243 | + } | |
| 244 | + | |
| 245 | + /** | |
| 246 | + * Is the page cache on? When it is, Cache::finalize_buffer() runs and our | |
| 247 | + * xspeed_cache_final_html filter fires — so we must NOT also ob_start(). | |
| 248 | + */ | |
| 249 | + private function cache_enabled(): bool { | |
| 250 | + $legacy = \XSpeed\Settings_Manager::get( 'legacy' ); | |
| 251 | + if ( is_array( $legacy ) && ! empty( $legacy['cache_enabled'] ) ) { | |
| 252 | + return true; | |
| 253 | + } | |
| 254 | + $opts = get_option( 'xspeed_options' ); | |
| 255 | + return is_array( $opts ) && ! empty( $opts['cache_enabled'] ); | |
| 256 | + } | |
| 257 | + | |
| 258 | + /** | |
| 259 | + * Settings changed — purge the page cache and re-sync the font-CORS | |
| 260 | + * rules in .htaccess. | |
| 261 | + */ | |
| 262 | + public function on_settings_change(): void { | |
| 263 | + $this->sync_font_cors(); | |
| 264 | + if ( class_exists( '\\XSpeed\\Cache' ) ) { | |
| 265 | + \XSpeed\Cache::purge_all( 'cdn settings change' ); | |
| 266 | + // purge_all() only reaches what we wrote. The attachment-URL | |
| 267 | + // filter below runs DURING render, so a page builder that caches | |
| 268 | + // rendered output has already stored the old host — Elementor | |
| 269 | + // keeps it in `_elementor_element_cache` for 24 h and in | |
| 270 | + // `uploads/elementor/css/post-<id>.css` with no expiry at all. | |
| 271 | + // Without this, turning the CDN OFF keeps serving the dead host | |
| 272 | + // (images 404 once the pull zone lapses) and turning it ON leaves | |
| 273 | + // the LCP hero on the origin — both for a day or more, both after | |
| 274 | + // a purge the user watched succeed. | |
| 275 | + \XSpeed\Cache::purge_render_caches( 'cdn settings change' ); | |
| 276 | + } | |
| 277 | + } | |
| 278 | + | |
| 279 | + /** | |
| 280 | + * Write (or remove) the Apache/LiteSpeed font-CORS block. | |
| 281 | + * | |
| 282 | + * nginx hosts get the same directives through nginx_directives() and the | |
| 283 | + * unified server-block snippet instead — we can't write their config. | |
| 284 | + */ | |
| 285 | + public function sync_font_cors(): void { | |
| 286 | + if ( ! class_exists( '\\XSpeed\\Server' ) || ! \XSpeed\Server::supports_htaccess() ) { | |
| 287 | + return; | |
| 288 | + } | |
| 289 | + if ( ! function_exists( 'insert_with_markers' ) ) { | |
| 290 | + require_once ABSPATH . 'wp-admin/includes/misc.php'; | |
| 291 | + } | |
| 292 | + if ( ! function_exists( 'insert_with_markers' ) ) { | |
| 293 | + return; | |
| 294 | + } | |
| 295 | + | |
| 296 | + $opts = $this->get_settings(); | |
| 297 | + $active = ! empty( $opts['enabled'] ) && ! empty( $opts['cdn_url'] ); | |
| 298 | + | |
| 299 | + $rules = $active | |
| 300 | + ? array( | |
| 301 | + '<IfModule mod_headers.c>', | |
| 302 | + ' # Allow the CDN to pull webfonts cross-origin.', | |
| 303 | + ' <FilesMatch "\\.(woff2?|ttf|otf|eot)$">', | |
| 304 | + ' Header always set Access-Control-Allow-Origin "*"', | |
| 305 | + ' </FilesMatch>', | |
| 306 | + '</IfModule>', | |
| 307 | + ) | |
| 308 | + : array(); | |
| 309 | + | |
| 310 | + // ABSPATH rather than get_home_path(): that function lives in | |
| 311 | + // wp-admin/includes/file.php, which is not loaded on a REST, CLI or | |
| 312 | + // cron request — and because this class is namespaced, the | |
| 313 | + // unqualified call resolved to XSpeed\Modules\Cdn\get_home_path() | |
| 314 | + // and fatalled on every real save, including disabling the module. | |
| 315 | + // This mirrors class-gzip.php, and the file_exists() guard it brings | |
| 316 | + // also stops insert_with_markers() creating a stray .htaccess at the | |
| 317 | + // WP root on a subdirectory install. | |
| 318 | + $htaccess = ABSPATH . '.htaccess'; | |
| 319 | + if ( ! file_exists( $htaccess ) ) { | |
| 320 | + // Nothing to amend, and nothing to clean up. | |
| 321 | + if ( empty( $rules ) ) { | |
| 322 | + return; | |
| 323 | + } | |
| 324 | + if ( ! is_writable( ABSPATH ) ) { | |
| 325 | + return; | |
| 326 | + } | |
| 327 | + } | |
| 328 | + | |
| 329 | + insert_with_markers( $htaccess, 'xSpeed CDN', $rules ); | |
| 330 | + } | |
| 331 | + | |
| 332 | + /** | |
| 333 | + * Font CORS for the origin. | |
| 334 | + * | |
| 335 | + * We ship the five font extensions enabled by default, and now that CSS | |
| 336 | + * actually reaches the CDN, `@font-face` inside those stylesheets | |
| 337 | + * resolves against the CDN host too. A font fetched cross-origin is a | |
| 338 | + * CORS request: without `Access-Control-Allow-Origin` on the ORIGIN | |
| 339 | + * response, the CDN caches a response the browser then refuses, and every | |
| 340 | + * webfont silently falls back to a system face. | |
| 341 | + * | |
| 342 | + * This was latent before — nothing reached the CDN, so nothing broke. | |
| 343 | + * Fixing the rewrite without this would turn a dead setting into a live | |
| 344 | + * regression, which is why it ships in the same change. | |
| 345 | + * | |
| 346 | + * @return string|null nginx directives, or null when the CDN is off. | |
| 347 | + */ | |
| 348 | + public function nginx_directives(): ?string { | |
| 349 | + $opts = $this->get_settings(); | |
| 350 | + if ( empty( $opts['enabled'] ) || empty( $opts['cdn_url'] ) ) { | |
| 351 | + return null; | |
| 352 | + } | |
| 353 | + return "# Allow the CDN to pull webfonts cross-origin.\n" | |
| 354 | + . "location ~* \\.(woff2?|ttf|otf|eot)$ {\n" | |
| 355 | + . " add_header Access-Control-Allow-Origin \"*\" always;\n" | |
| 356 | + . "}"; | |
| 357 | + } | |
| 358 | + | |
| 359 | + /** | |
| 360 | + * Emit a preconnect hint for the CDN host. | |
| 361 | + * | |
| 362 | + * @param array $hints URLs for this relation type. | |
| 363 | + * @param string $relation_type One of dns-prefetch / preconnect / … | |
| 364 | + * @return array | |
| 365 | + */ | |
| 366 | + public function add_preconnect( $hints, $relation_type ) { | |
| 367 | + if ( 'preconnect' !== $relation_type || ! is_array( $hints ) ) { | |
| 368 | + return $hints; | |
| 369 | + } | |
| 370 | + if ( Cdn_Rewriter::is_dev_host() ) { | |
| 371 | + return $hints; | |
| 372 | + } | |
| 373 | + $opts = $this->get_settings(); | |
| 374 | + $host = Cdn_Rewriter::normalize_host( (string) ( $opts['cdn_url'] ?? '' ) ); | |
| 375 | + if ( '' === $host ) { | |
| 376 | + return $hints; | |
| 377 | + } | |
| 378 | + // crossorigin so the hint also warms the connection fonts will use — | |
| 379 | + // font requests are CORS requests and would otherwise open a second | |
| 380 | + // connection. | |
| 381 | + $hints[] = array( | |
| 382 | + 'href' => '//' . $host, | |
| 383 | + 'crossorigin' => 'anonymous', | |
| 384 | + ); | |
| 385 | + return $hints; | |
| 386 | + } | |
| 387 | + | |
| 94 | 388 | public function rewrite_attachment_url( $url ) { |
| 95 | 389 | if ( ! is_string( $url ) || '' === $url ) { |
| 96 | 390 | return $url; |
| 97 | 391 | } |
| @@ -120,8 +414,9 @@ | ||
| 120 | 414 | array( |
| 121 | 415 | 'name' => 'xspeed cdn', |
| 122 | 416 | 'callback' => array( $this, 'cli_handler' ), |
| 123 | 417 | 'shortdesc' => 'Show CDN settings + test rewriting a URL.', |
| 418 | + 'ai_hint' => 'Is a CDN configured, and does URL rewriting work? Use to check whether assets are served from the CDN, or to test what a given URL rewrites to before trusting the setting.', | |
| 124 | 419 | 'synopsis' => array( |
| 125 | 420 | array( |
| 126 | 421 | 'type' => 'positional', |
| 127 | 422 | 'name' => 'action', |