PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
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.2.0, at includes/modules/Cdn/CdnModule.php

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