PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
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 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / modules / Cdn / CdnModule.php

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

455 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 static assets (images, fonts, CSS, JS) from a pull-zone CDN host like BunnyCDN, KeyCDN, or your own.', 'xspeed' ),
31 );
32 }
33
34 public function settings_schema(): array {
35 return array(
36 'enabled' => array(
37 'type' => 'bool',
38 'default' => false,
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' ),
41 ),
42 'cdn_url' => array(
43 'type' => 'string',
44 'default' => '',
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' ),
48 ),
49 'included_extensions' => array(
50 'type' => 'list',
51 'default' => Cdn_Rewriter::DEFAULT_EXTENSIONS,
52 'item_type' => 'string',
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' ),
56 ),
57 'excluded_patterns' => array(
58 'type' => 'list',
59 'default' => array(),
60 'item_type' => 'string',
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' ),
64 ),
65 );
66 }
67
68 public function conflicts(): array {
69 return array(
70 array(
71 'plugin' => 'cdn-enabler/cdn-enabler.php',
72 'feature' => 'cdn.rewrite',
73 'strategy' => \XSpeed\Conflict_Registry::STRATEGY_REFUSE,
74 'reason' => 'CDN Enabler rewrites the same URLs; running both will double-rewrite or produce broken hosts.',
75 ),
76 );
77 }
78
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 {
99 // Always-on: normalize cdn_url on save (admin context too).
100 add_filter( 'pre_update_option_xspeed_module_cdn', array( $this, 'normalize_on_save' ), 10, 1 );
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
109 if ( is_admin() || ( defined( 'DOING_AJAX' ) && DOING_AJAX ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
110 return;
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 }
118 $opts = $this->get_settings();
119 if ( empty( $opts['enabled'] ) || empty( $opts['cdn_url'] ) ) {
120 return;
121 }
122 Cdn_Rewriter::reset_state();
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.
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 }
195 }
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
388 public function rewrite_attachment_url( $url ) {
389 if ( ! is_string( $url ) || '' === $url ) {
390 return $url;
391 }
392 return Cdn_Rewriter::rewrite_url( $url, $this->get_settings() );
393 }
394
395 /**
396 * pre_update_option filter — strips https:// + trailing slash from
397 * cdn_url before storage, so we always work against a bare host.
398 *
399 * @param mixed $value
400 * @return mixed
401 */
402 public function normalize_on_save( $value ) {
403 if ( ! is_array( $value ) ) {
404 return $value;
405 }
406 if ( isset( $value['cdn_url'] ) ) {
407 $value['cdn_url'] = Cdn_Rewriter::normalize_host( (string) $value['cdn_url'] );
408 }
409 return $value;
410 }
411
412 public function cli_commands(): array {
413 return array(
414 array(
415 'name' => 'xspeed cdn',
416 'callback' => array( $this, 'cli_handler' ),
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.',
419 'synopsis' => array(
420 array(
421 'type' => 'positional',
422 'name' => 'action',
423 'options' => array( 'status', 'test' ),
424 'optional' => true,
425 ),
426 array(
427 'type' => 'assoc',
428 'name' => 'url',
429 'optional' => true,
430 ),
431 ),
432 ),
433 );
434 }
435
436 public function cli_handler( array $args, array $assoc ): void {
437 $action = $args[0] ?? 'status';
438 $opts = $this->get_settings();
439 if ( 'test' === $action ) {
440 $url = isset( $assoc['url'] ) ? (string) $assoc['url'] : '';
441 if ( '' === $url ) {
442 \WP_CLI::error( 'Pass --url=<url> to test rewriting.' );
443 }
444 Cdn_Rewriter::reset_state();
445 \WP_CLI::log( 'in: ' . $url );
446 \WP_CLI::log( 'out: ' . Cdn_Rewriter::rewrite_url( $url, $opts ) );
447 return;
448 }
449 \WP_CLI::log( sprintf( '%-22s %s', 'enabled', ! empty( $opts['enabled'] ) ? 'on' : 'off' ) );
450 \WP_CLI::log( sprintf( '%-22s %s', 'cdn_url', (string) ( $opts['cdn_url'] ?? '' ) ) );
451 \WP_CLI::log( sprintf( '%-22s %s', 'included_extensions', implode( ',', (array) ( $opts['included_extensions'] ?? array() ) ) ) );
452 \WP_CLI::log( sprintf( '%-22s %s', 'excluded_patterns', implode( ',', (array) ( $opts['excluded_patterns'] ?? array() ) ) ) );
453 }
454 }
455