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

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

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