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

419 lines 13.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 $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 }
242 }
243
244 /**
245 * Write (or remove) the Apache/LiteSpeed font-CORS block.
246 *
247 * nginx hosts get the same directives through nginx_directives() and the
248 * unified server-block snippet instead — we can't write their config.
249 */
250 public function sync_font_cors(): void {
251 if ( ! class_exists( '\\XSpeed\\Server' ) || ! \XSpeed\Server::supports_htaccess() ) {
252 return;
253 }
254 if ( ! function_exists( 'insert_with_markers' ) ) {
255 require_once ABSPATH . 'wp-admin/includes/misc.php';
256 }
257 if ( ! function_exists( 'insert_with_markers' ) ) {
258 return;
259 }
260
261 $opts = $this->get_settings();
262 $active = ! empty( $opts['enabled'] ) && ! empty( $opts['cdn_url'] );
263
264 $rules = $active
265 ? array(
266 '<IfModule mod_headers.c>',
267 ' # Allow the CDN to pull webfonts cross-origin.',
268 ' <FilesMatch "\\.(woff2?|ttf|otf|eot)$">',
269 ' Header always set Access-Control-Allow-Origin "*"',
270 ' </FilesMatch>',
271 '</IfModule>',
272 )
273 : array();
274
275 // ABSPATH rather than get_home_path(): that function lives in
276 // wp-admin/includes/file.php, which is not loaded on a REST, CLI or
277 // cron request — and because this class is namespaced, the
278 // unqualified call resolved to XSpeed\Modules\Cdn\get_home_path()
279 // and fatalled on every real save, including disabling the module.
280 // This mirrors class-gzip.php, and the file_exists() guard it brings
281 // also stops insert_with_markers() creating a stray .htaccess at the
282 // WP root on a subdirectory install.
283 $htaccess = ABSPATH . '.htaccess';
284 if ( ! file_exists( $htaccess ) ) {
285 // Nothing to amend, and nothing to clean up.
286 if ( empty( $rules ) ) {
287 return;
288 }
289 if ( ! is_writable( ABSPATH ) ) {
290 return;
291 }
292 }
293
294 insert_with_markers( $htaccess, 'xSpeed CDN', $rules );
295 }
296
297 /**
298 * Font CORS for the origin.
299 *
300 * We ship the five font extensions enabled by default, and now that CSS
301 * actually reaches the CDN, `@font-face` inside those stylesheets
302 * resolves against the CDN host too. A font fetched cross-origin is a
303 * CORS request: without `Access-Control-Allow-Origin` on the ORIGIN
304 * response, the CDN caches a response the browser then refuses, and every
305 * webfont silently falls back to a system face.
306 *
307 * This was latent before — nothing reached the CDN, so nothing broke.
308 * Fixing the rewrite without this would turn a dead setting into a live
309 * regression, which is why it ships in the same change.
310 *
311 * @return string|null nginx directives, or null when the CDN is off.
312 */
313 public function nginx_directives(): ?string {
314 $opts = $this->get_settings();
315 if ( empty( $opts['enabled'] ) || empty( $opts['cdn_url'] ) ) {
316 return null;
317 }
318 return "# Allow the CDN to pull webfonts cross-origin.\n"
319 . "location ~* \\.(woff2?|ttf|otf|eot)$ {\n"
320 . " add_header Access-Control-Allow-Origin \"*\" always;\n"
321 . "}";
322 }
323
324 /**
325 * Emit a preconnect hint for the CDN host.
326 *
327 * @param array $hints URLs for this relation type.
328 * @param string $relation_type One of dns-prefetch / preconnect / …
329 * @return array
330 */
331 public function add_preconnect( $hints, $relation_type ) {
332 if ( 'preconnect' !== $relation_type || ! is_array( $hints ) ) {
333 return $hints;
334 }
335 if ( Cdn_Rewriter::is_dev_host() ) {
336 return $hints;
337 }
338 $opts = $this->get_settings();
339 $host = Cdn_Rewriter::normalize_host( (string) ( $opts['cdn_url'] ?? '' ) );
340 if ( '' === $host ) {
341 return $hints;
342 }
343 // crossorigin so the hint also warms the connection fonts will use —
344 // font requests are CORS requests and would otherwise open a second
345 // connection.
346 $hints[] = array(
347 'href' => '//' . $host,
348 'crossorigin' => 'anonymous',
349 );
350 return $hints;
351 }
352
353 public function rewrite_attachment_url( $url ) {
354 if ( ! is_string( $url ) || '' === $url ) {
355 return $url;
356 }
357 return Cdn_Rewriter::rewrite_url( $url, $this->get_settings() );
358 }
359
360 /**
361 * pre_update_option filter — strips https:// + trailing slash from
362 * cdn_url before storage, so we always work against a bare host.
363 *
364 * @param mixed $value
365 * @return mixed
366 */
367 public function normalize_on_save( $value ) {
368 if ( ! is_array( $value ) ) {
369 return $value;
370 }
371 if ( isset( $value['cdn_url'] ) ) {
372 $value['cdn_url'] = Cdn_Rewriter::normalize_host( (string) $value['cdn_url'] );
373 }
374 return $value;
375 }
376
377 public function cli_commands(): array {
378 return array(
379 array(
380 'name' => 'xspeed cdn',
381 'callback' => array( $this, 'cli_handler' ),
382 'shortdesc' => 'Show CDN settings + test rewriting a URL.',
383 'synopsis' => array(
384 array(
385 'type' => 'positional',
386 'name' => 'action',
387 'options' => array( 'status', 'test' ),
388 'optional' => true,
389 ),
390 array(
391 'type' => 'assoc',
392 'name' => 'url',
393 'optional' => true,
394 ),
395 ),
396 ),
397 );
398 }
399
400 public function cli_handler( array $args, array $assoc ): void {
401 $action = $args[0] ?? 'status';
402 $opts = $this->get_settings();
403 if ( 'test' === $action ) {
404 $url = isset( $assoc['url'] ) ? (string) $assoc['url'] : '';
405 if ( '' === $url ) {
406 \WP_CLI::error( 'Pass --url=<url> to test rewriting.' );
407 }
408 Cdn_Rewriter::reset_state();
409 \WP_CLI::log( 'in: ' . $url );
410 \WP_CLI::log( 'out: ' . Cdn_Rewriter::rewrite_url( $url, $opts ) );
411 return;
412 }
413 \WP_CLI::log( sprintf( '%-22s %s', 'enabled', ! empty( $opts['enabled'] ) ? 'on' : 'off' ) );
414 \WP_CLI::log( sprintf( '%-22s %s', 'cdn_url', (string) ( $opts['cdn_url'] ?? '' ) ) );
415 \WP_CLI::log( sprintf( '%-22s %s', 'included_extensions', implode( ',', (array) ( $opts['included_extensions'] ?? array() ) ) ) );
416 \WP_CLI::log( sprintf( '%-22s %s', 'excluded_patterns', implode( ',', (array) ( $opts['excluded_patterns'] ?? array() ) ) ) );
417 }
418 }
419