PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.0
1.4.1 1.4.0 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 All 35 releases
xspeed / includes / modules / Cdn / CdnModule.php

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

462 lines 16.0 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 // Something else on the site has taken over serving these files. The
124 // switch stays as the owner left it, and nothing is rewritten while
125 // the block lasts. See Module::blocked_by().
126 if ( null !== $this->blocked_by() ) {
127 return;
128 }
129 Cdn_Rewriter::reset_state();
130
131 // Attachment URLs still go through their own filter: media-library
132 // URLs are frequently consumed as PHP strings (feeds, oEmbed, REST
133 // echoes) rather than emitted into the page HTML we rewrite below.
134 add_filter( 'wp_get_attachment_url', array( $this, 'rewrite_attachment_url' ), 1000 );
135
136 // Preconnect to the CDN host. Every asset on the page now resolves
137 // there, so paying the DNS + TLS handshake once up front rather than
138 // on first asset request is worth the one tag.
139 add_filter( 'wp_resource_hints', array( $this, 'add_preconnect' ), 10, 2 );
140
141 // Whole-page pass.
142 //
143 // This module used to hook only the_content, post_thumbnail_html and
144 // widget_text_content — four filters that between them can never
145 // contain a stylesheet, a script or a font. So `css`, `js` and the
146 // five font extensions shipped ticked by default and rewrote nothing:
147 // a user enabled the CDN, saw them enabled, and found zero requests
148 // in their pull zone.
149 //
150 // Enqueued assets can't be reached with those filters at all, and
151 // hooking style_loader_src/script_loader_src would still miss inline
152 // url(), hardcoded theme-template images and third-party echo output.
153 // One pass over the finished page catches every category at once.
154 //
155 // It also fixes the srcset split: core builds srcset from
156 // wp_get_upload_dir() and never calls wp_get_attachment_url(), so a
157 // theme image previously got a CDN `src` and an origin `srcset` in
158 // the same tag.
159 //
160 // Cost: on the cache-write path this runs once per MISS and the CDN
161 // URLs bake into the stored HTML, so cache HITs pay nothing. This is
162 // what Powered Cache, Breeze and SpeedyCache all do. The trade-off is
163 // that turning the CDN off needs a cache purge — handled by
164 // purge_on_change() below.
165 add_filter(
166 'xspeed_cache_final_html',
167 static function ( $html ) {
168 if ( ! self::should_rewrite_request() ) {
169 return $html;
170 }
171 return Cdn_Rewriter::process_html( (string) $html );
172 },
173 // After Resource Hints (10) so any preload/preconnect tag it
174 // injects gets its URL rewritten too.
175 20,
176 1
177 );
178
179 // Cache-off path: the filter above never fires, so buffer the page
180 // ourselves. Guarded so we never double-buffer when the cache engine
181 // is running.
182 if ( ! $this->cache_enabled() ) {
183 add_action(
184 'template_redirect',
185 static function () {
186 if ( self::$buffering || ! self::should_rewrite_request() ) {
187 return;
188 }
189 self::$buffering = true;
190 ob_start(
191 static function ( $buffer ) {
192 if ( strlen( (string) $buffer ) < 255 ) {
193 return $buffer;
194 }
195 return Cdn_Rewriter::process_html( (string) $buffer );
196 }
197 );
198 },
199 9
200 );
201 }
202 }
203
204 /**
205 * Guard against opening our buffer twice on one request.
206 *
207 * @var bool
208 */
209 private static $buffering = false;
210
211 /**
212 * Should this request have its asset URLs rewritten at all?
213 *
214 * The module's original bail set covered admin / AJAX / cron / REST only.
215 * These four are the remaining request types where a CDN URL is either
216 * wrong or actively unhelpful:
217 *
218 * - Previews render unsaved content for one logged-in author; pointing
219 * their assets at a pull zone caches a draft at the edge.
220 * - robots.txt and trackbacks are not HTML and have no assets.
221 * - Non-GET requests are form posts and API calls, never a page whose
222 * asset URLs matter.
223 */
224 public static function should_rewrite_request(): bool {
225 $method = isset( $_SERVER['REQUEST_METHOD'] )
226 ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) )
227 : 'GET';
228 if ( 'GET' !== $method && 'HEAD' !== $method ) {
229 return false;
230 }
231 if ( function_exists( 'is_preview' ) && is_preview() ) {
232 return false;
233 }
234 if ( function_exists( 'is_robots' ) && is_robots() ) {
235 return false;
236 }
237 if ( function_exists( 'is_trackback' ) && is_trackback() ) {
238 return false;
239 }
240 if ( function_exists( 'is_feed' ) && is_feed() ) {
241 return false;
242 }
243
244 /**
245 * Final say on whether to rewrite asset URLs for this request.
246 *
247 * @param bool $should Whether to rewrite.
248 */
249 return (bool) apply_filters( 'xspeed_cdn_should_rewrite', true );
250 }
251
252 /**
253 * Is the page cache on? When it is, Cache::finalize_buffer() runs and our
254 * xspeed_cache_final_html filter fires — so we must NOT also ob_start().
255 */
256 private function cache_enabled(): bool {
257 $legacy = \XSpeed\Settings_Manager::get( 'legacy' );
258 if ( is_array( $legacy ) && ! empty( $legacy['cache_enabled'] ) ) {
259 return true;
260 }
261 $opts = get_option( 'xspeed_options' );
262 return is_array( $opts ) && ! empty( $opts['cache_enabled'] );
263 }
264
265 /**
266 * Settings changed — purge the page cache and re-sync the font-CORS
267 * rules in .htaccess.
268 */
269 public function on_settings_change(): void {
270 $this->sync_font_cors();
271 if ( class_exists( '\\XSpeed\\Cache' ) ) {
272 \XSpeed\Cache::purge_all( 'cdn settings change' );
273 // purge_all() only reaches what we wrote. The attachment-URL
274 // filter below runs DURING render, so a page builder that caches
275 // rendered output has already stored the old host — Elementor
276 // keeps it in `_elementor_element_cache` for 24 h and in
277 // `uploads/elementor/css/post-<id>.css` with no expiry at all.
278 // Without this, turning the CDN OFF keeps serving the dead host
279 // (images 404 once the pull zone lapses) and turning it ON leaves
280 // the LCP hero on the origin — both for a day or more, both after
281 // a purge the user watched succeed.
282 \XSpeed\Cache::purge_render_caches( 'cdn settings change' );
283 }
284 }
285
286 /**
287 * Write (or remove) the Apache/LiteSpeed font-CORS block.
288 *
289 * nginx hosts get the same directives through nginx_directives() and the
290 * unified server-block snippet instead — we can't write their config.
291 */
292 public function sync_font_cors(): void {
293 if ( ! class_exists( '\\XSpeed\\Server' ) || ! \XSpeed\Server::supports_htaccess() ) {
294 return;
295 }
296 if ( ! function_exists( 'insert_with_markers' ) ) {
297 require_once ABSPATH . 'wp-admin/includes/misc.php';
298 }
299 if ( ! function_exists( 'insert_with_markers' ) ) {
300 return;
301 }
302
303 $opts = $this->get_settings();
304 $active = ! empty( $opts['enabled'] ) && ! empty( $opts['cdn_url'] );
305
306 $rules = $active
307 ? array(
308 '<IfModule mod_headers.c>',
309 ' # Allow the CDN to pull webfonts cross-origin.',
310 ' <FilesMatch "\\.(woff2?|ttf|otf|eot)$">',
311 ' Header always set Access-Control-Allow-Origin "*"',
312 ' </FilesMatch>',
313 '</IfModule>',
314 )
315 : array();
316
317 // ABSPATH rather than get_home_path(): that function lives in
318 // wp-admin/includes/file.php, which is not loaded on a REST, CLI or
319 // cron request — and because this class is namespaced, the
320 // unqualified call resolved to XSpeed\Modules\Cdn\get_home_path()
321 // and fatalled on every real save, including disabling the module.
322 // This mirrors class-gzip.php, and the file_exists() guard it brings
323 // also stops insert_with_markers() creating a stray .htaccess at the
324 // WP root on a subdirectory install.
325 $htaccess = ABSPATH . '.htaccess';
326 if ( ! file_exists( $htaccess ) ) {
327 // Nothing to amend, and nothing to clean up.
328 if ( empty( $rules ) ) {
329 return;
330 }
331 if ( ! is_writable( ABSPATH ) ) {
332 return;
333 }
334 }
335
336 insert_with_markers( $htaccess, 'xSpeed CDN', $rules );
337 }
338
339 /**
340 * Font CORS for the origin.
341 *
342 * We ship the five font extensions enabled by default, and now that CSS
343 * actually reaches the CDN, `@font-face` inside those stylesheets
344 * resolves against the CDN host too. A font fetched cross-origin is a
345 * CORS request: without `Access-Control-Allow-Origin` on the ORIGIN
346 * response, the CDN caches a response the browser then refuses, and every
347 * webfont silently falls back to a system face.
348 *
349 * This was latent before — nothing reached the CDN, so nothing broke.
350 * Fixing the rewrite without this would turn a dead setting into a live
351 * regression, which is why it ships in the same change.
352 *
353 * @return string|null nginx directives, or null when the CDN is off.
354 */
355 public function nginx_directives(): ?string {
356 $opts = $this->get_settings();
357 if ( empty( $opts['enabled'] ) || empty( $opts['cdn_url'] ) ) {
358 return null;
359 }
360 return "# Allow the CDN to pull webfonts cross-origin.\n"
361 . "location ~* \\.(woff2?|ttf|otf|eot)$ {\n"
362 . " add_header Access-Control-Allow-Origin \"*\" always;\n"
363 . "}";
364 }
365
366 /**
367 * Emit a preconnect hint for the CDN host.
368 *
369 * @param array $hints URLs for this relation type.
370 * @param string $relation_type One of dns-prefetch / preconnect / …
371 * @return array
372 */
373 public function add_preconnect( $hints, $relation_type ) {
374 if ( 'preconnect' !== $relation_type || ! is_array( $hints ) ) {
375 return $hints;
376 }
377 if ( Cdn_Rewriter::is_dev_host() ) {
378 return $hints;
379 }
380 $opts = $this->get_settings();
381 $host = Cdn_Rewriter::normalize_host( (string) ( $opts['cdn_url'] ?? '' ) );
382 if ( '' === $host ) {
383 return $hints;
384 }
385 // crossorigin so the hint also warms the connection fonts will use —
386 // font requests are CORS requests and would otherwise open a second
387 // connection.
388 $hints[] = array(
389 'href' => '//' . $host,
390 'crossorigin' => 'anonymous',
391 );
392 return $hints;
393 }
394
395 public function rewrite_attachment_url( $url ) {
396 if ( ! is_string( $url ) || '' === $url ) {
397 return $url;
398 }
399 return Cdn_Rewriter::rewrite_url( $url, $this->get_settings() );
400 }
401
402 /**
403 * pre_update_option filter — strips https:// + trailing slash from
404 * cdn_url before storage, so we always work against a bare host.
405 *
406 * @param mixed $value
407 * @return mixed
408 */
409 public function normalize_on_save( $value ) {
410 if ( ! is_array( $value ) ) {
411 return $value;
412 }
413 if ( isset( $value['cdn_url'] ) ) {
414 $value['cdn_url'] = Cdn_Rewriter::normalize_host( (string) $value['cdn_url'] );
415 }
416 return $value;
417 }
418
419 public function cli_commands(): array {
420 return array(
421 array(
422 'name' => 'xspeed cdn',
423 'callback' => array( $this, 'cli_handler' ),
424 'shortdesc' => 'Show CDN settings + test rewriting a URL.',
425 '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.',
426 'synopsis' => array(
427 array(
428 'type' => 'positional',
429 'name' => 'action',
430 'options' => array( 'status', 'test' ),
431 'optional' => true,
432 ),
433 array(
434 'type' => 'assoc',
435 'name' => 'url',
436 'optional' => true,
437 ),
438 ),
439 ),
440 );
441 }
442
443 public function cli_handler( array $args, array $assoc ): void {
444 $action = $args[0] ?? 'status';
445 $opts = $this->get_settings();
446 if ( 'test' === $action ) {
447 $url = isset( $assoc['url'] ) ? (string) $assoc['url'] : '';
448 if ( '' === $url ) {
449 \WP_CLI::error( 'Pass --url=<url> to test rewriting.' );
450 }
451 Cdn_Rewriter::reset_state();
452 \WP_CLI::log( 'in: ' . $url );
453 \WP_CLI::log( 'out: ' . Cdn_Rewriter::rewrite_url( $url, $opts ) );
454 return;
455 }
456 \WP_CLI::log( sprintf( '%-22s %s', 'enabled', ! empty( $opts['enabled'] ) ? 'on' : 'off' ) );
457 \WP_CLI::log( sprintf( '%-22s %s', 'cdn_url', (string) ( $opts['cdn_url'] ?? '' ) ) );
458 \WP_CLI::log( sprintf( '%-22s %s', 'included_extensions', implode( ',', (array) ( $opts['included_extensions'] ?? array() ) ) ) );
459 \WP_CLI::log( sprintf( '%-22s %s', 'excluded_patterns', implode( ',', (array) ( $opts['excluded_patterns'] ?? array() ) ) ) );
460 }
461 }
462