PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 33 releases
xspeed / includes / modules / Cdn / CdnModule.php

CdnModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.7, at includes/modules/Cdn/CdnModule.php

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