PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
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 1.1.3 1.1.4 All 33 releases
xspeed / includes / modules / Bloat / BloatModule.php

BloatModule.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.7, at includes/modules/Bloat/BloatModule.php

482 lines 17.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Bloat — disable WordPress features site owners rarely use but every
4 * frontend pays for in bytes / requests / attack surface.
5 *
6 * Each setting is a single toggle that adds (or doesn't add) one or
7 * two filters. Per the SETTINGS.md standard, every toggle ships with a
8 * label + description that names the actual ergonomic value.
9 *
10 * Every toggle is opt-in (default false). The defaults are
11 * conservative because every site has at least one plugin that quietly
12 * depends on the surface this module strips — better to make the user
13 * choose than to break themes on activation.
14 *
15 * Tier: Free (FEATURES.md "Others" §10-§15 — declared in commit
16 * `4e36051` before this implementation).
17 *
18 * @package XSpeed
19 */
20
21 declare(strict_types=1);
22
23 namespace XSpeed\Modules\Bloat;
24
25 defined( 'ABSPATH' ) || exit;
26
27 use XSpeed\Module;
28 use XSpeed\Settings_Manager;
29
30 final class BloatModule extends Module {
31
32 public const SLUG = 'bloat';
33 public const TIER = self::TIER_FREE;
34 public const VERSION = '1.0.0';
35
36 public function ui_metadata(): array {
37 return array(
38 'label' => __( 'Bloat Control', 'xspeed' ),
39 'icon' => 'Sliders',
40 'description' => __( 'Turns off WordPress features you do not use, so pages load less.', 'xspeed' ),
41 'group' => 'performance',
42 );
43 }
44
45 public function settings_schema(): array {
46 return array(
47 'disable_emojis' => array(
48 'type' => 'bool',
49 'default' => false,
50 'label' => __( 'Disable emojis', 'xspeed' ),
51 'description' => __( 'Remove the emoji detection script and its inline styles from every page. Modern browsers draw emojis natively, so visitors still see them. Saves a script and an inline stylesheet per page.', 'xspeed' ),
52 ),
53 'disable_dashicons_frontend' => array(
54 'type' => 'bool',
55 'default' => false,
56 'label' => __( 'Remove Dashicons for visitors', 'xspeed' ),
57 'description' => __( 'Removes the WordPress admin icon font for logged-out visitors. Most themes do not use it, and it saves about 45 KB.', 'xspeed' ),
58 ),
59 'disable_oembed' => array(
60 'type' => 'bool',
61 'default' => false,
62 'label' => __( 'Disable auto-embeds', 'xspeed' ),
63 'description' => __( 'Removes the embed script, saving one request per page. A pasted YouTube link no longer turns into a player, so use an embed block.', 'xspeed' ),
64 ),
65 'disable_rss_feeds' => array(
66 'type' => 'bool',
67 'default' => false,
68 'label' => __( 'Disable RSS feeds', 'xspeed' ),
69 'description' => __( 'Feed addresses such as /feed/ return "not found". Use this if your site has no feed readers.', 'xspeed' ),
70 ),
71 'disable_xmlrpc' => array(
72 'type' => 'bool',
73 'default' => false,
74 'label' => __( 'Disable XML-RPC', 'xspeed' ),
75 'description' => __( 'Turns off the old xmlrpc.php file that attackers often target. Leave this off if you use Jetpack or the WordPress mobile app.', 'xspeed' ),
76 ),
77 'strip_jquery_migrate' => array(
78 'type' => 'bool',
79 'default' => false,
80 'label' => __( 'Remove jQuery Migrate', 'xspeed' ),
81 'description' => __( 'Removes a script that old themes and plugins need, from pages visitors see. Saves about 10 KB and is safe on current themes.', 'xspeed' ),
82 ),
83 'strip_editor_styles' => array(
84 'type' => 'bool',
85 'default' => false,
86 'label' => __( 'Remove editor styles for visitors', 'xspeed' ),
87 'description' => __( 'Removes block editor CSS that some plugins load on public pages by mistake, which can add hundreds of KB. Block styles for visitors stay.', 'xspeed' ),
88 ),
89 'remove_rsd_link' => array(
90 'type' => 'bool',
91 'default' => false,
92 'label' => __( 'Remove RSD link', 'xspeed' ),
93 'description' => __( 'Drop the Really Simple Discovery link from the page head. Only old desktop blogging clients read it.', 'xspeed' ),
94 ),
95 'remove_shortlink' => array(
96 'type' => 'bool',
97 'default' => false,
98 'label' => __( 'Remove shortlink', 'xspeed' ),
99 'description' => __( 'Drop the ?p=123 shortlink tag and header from posts and pages. The shortlinks keep working; they are just no longer advertised.', 'xspeed' ),
100 ),
101 'remove_rest_api_links' => array(
102 'type' => 'bool',
103 'default' => false,
104 'label' => __( 'Remove REST API links', 'xspeed' ),
105 'description' => __( 'Drop the /wp-json/ discovery link tag and Link header. The REST API itself stays on; to block it, use the setting below.', 'xspeed' ),
106 ),
107 'hide_wp_version' => array(
108 'type' => 'bool',
109 'default' => false,
110 'label' => __( 'Hide WordPress version', 'xspeed' ),
111 'description' => __( 'Remove the WordPress generator tag from pages and feeds, so it no longer states the WordPress version. Other plugins print their own tags; those stay. Script and style URLs still carry ?ver= numbers.', 'xspeed' ),
112 ),
113 'disable_self_pingbacks' => array(
114 'type' => 'bool',
115 'default' => false,
116 'label' => __( 'Disable self-pingbacks', 'xspeed' ),
117 'description' => __( 'Stop WordPress from sending a pingback to your own site when a post links to another of your posts. Pingbacks to other sites are not affected.', 'xspeed' ),
118 ),
119 'restrict_rest_to_authed' => array(
120 'type' => 'bool',
121 'default' => false,
122 'label' => __( 'REST API for logged-in users only', 'xspeed' ),
123 'description' => __( 'Blocks /wp-json/ for logged-out visitors. This breaks WooCommerce checkout and many contact forms, so keep it off unless you are sure.', 'xspeed' ),
124 'advanced' => true,
125 ),
126 );
127 }
128
129 public function boot(): void {
130 /*
131 * Deferred to `init` priority 0. This reads the module's settings,
132 * which builds settings_schema(), whose labels go through __(), and
133 * boot() runs on `plugins_loaded` — before `after_setup_theme`, the
134 * earliest point WordPress 6.7+ treats as safe to translate.
135 *
136 * Priority 0 (not the default 10) because the body itself registers
137 * an `init` callback at priority 9: adding a hook to the action that
138 * is currently running only takes effect if the new priority is still
139 * ahead of the running position, so we have to be first. Every other
140 * hook it registers fires later than `init`.
141 */
142 add_action( 'init', array( $this, 'boot_on_init' ), 0 );
143 }
144
145 /**
146 * The real boot body — see boot() for why it runs on `init`.
147 */
148 public function boot_on_init(): void {
149 $opts = Settings_Manager::get( self::SLUG );
150
151 if ( ! empty( $opts['disable_emojis'] ) ) {
152 self::disable_emojis();
153 }
154
155 if ( ! empty( $opts['disable_dashicons_frontend'] ) ) {
156 add_action( 'wp_enqueue_scripts', array( __CLASS__, 'dequeue_dashicons' ), 100 );
157 }
158
159 if ( ! empty( $opts['disable_oembed'] ) ) {
160 add_action( 'init', array( __CLASS__, 'disable_oembed' ), 9 );
161 }
162
163 if ( ! empty( $opts['disable_rss_feeds'] ) ) {
164 add_action( 'do_feed', array( __CLASS__, 'block_feed' ), 1 );
165 add_action( 'do_feed_rdf', array( __CLASS__, 'block_feed' ), 1 );
166 add_action( 'do_feed_rss', array( __CLASS__, 'block_feed' ), 1 );
167 add_action( 'do_feed_rss2', array( __CLASS__, 'block_feed' ), 1 );
168 add_action( 'do_feed_atom', array( __CLASS__, 'block_feed' ), 1 );
169 add_action( 'do_feed_rss2_comments', array( __CLASS__, 'block_feed' ), 1 );
170 add_action( 'do_feed_atom_comments', array( __CLASS__, 'block_feed' ), 1 );
171 }
172
173 if ( ! empty( $opts['disable_xmlrpc'] ) ) {
174 add_filter( 'xmlrpc_enabled', '__return_false' );
175 add_filter( 'wp_headers', array( __CLASS__, 'strip_xmlrpc_header' ) );
176 add_filter( 'pings_open', '__return_false' );
177 }
178
179 if ( ! empty( $opts['strip_jquery_migrate'] ) ) {
180 add_action( 'wp_default_scripts', array( __CLASS__, 'strip_jquery_migrate' ) );
181 /*
182 * `wp_default_scripts` fires once, when something first builds the
183 * script registry. A plugin that registers a script while it loads
184 * (Elementor Pro's Forms module registers its reCAPTCHA script) does
185 * that before this runs on `init`, so the hook above never fires and
186 * Migrate stays. Strip it from the registry that already exists,
187 * before the page enqueues anything. (#587)
188 */
189 if ( did_action( 'wp_default_scripts' ) ) {
190 add_action( 'wp_enqueue_scripts', array( __CLASS__, 'strip_jquery_migrate_now' ), 0 );
191 }
192 }
193
194 if ( ! empty( $opts['strip_editor_styles'] ) ) {
195 // Late, so anything enqueued at normal priority is already queued.
196 add_action( 'wp_enqueue_scripts', array( __CLASS__, 'dequeue_editor_styles' ), PHP_INT_MAX );
197 }
198
199 if ( ! empty( $opts['remove_rsd_link'] ) ) {
200 remove_action( 'wp_head', 'rsd_link' );
201 }
202
203 if ( ! empty( $opts['remove_shortlink'] ) ) {
204 remove_action( 'wp_head', 'wp_shortlink_wp_head' );
205 remove_action( 'template_redirect', 'wp_shortlink_header', 11 );
206 }
207
208 if ( ! empty( $opts['remove_rest_api_links'] ) ) {
209 remove_action( 'wp_head', 'rest_output_link_wp_head' );
210 remove_action( 'template_redirect', 'rest_output_link_header', 11 );
211 remove_action( 'xmlrpc_rsd_apis', 'rest_output_rsd' );
212 }
213
214 if ( ! empty( $opts['hide_wp_version'] ) ) {
215 remove_action( 'wp_head', 'wp_generator' );
216 add_filter( 'the_generator', '__return_empty_string' );
217 }
218
219 if ( ! empty( $opts['disable_self_pingbacks'] ) ) {
220 add_action( 'pre_ping', array( __CLASS__, 'strip_self_pings' ) );
221 }
222
223 if ( ! empty( $opts['restrict_rest_to_authed'] ) ) {
224 add_filter( 'rest_authentication_errors', array( __CLASS__, 'restrict_rest' ) );
225 }
226 }
227
228 public static function dequeue_dashicons(): void {
229 if ( is_admin_bar_showing() || is_user_logged_in() ) {
230 return; // the admin bar uses dashicons; only strip on truly anonymous pages.
231 }
232 wp_dequeue_style( 'dashicons' );
233 wp_deregister_style( 'dashicons' );
234 }
235
236 /**
237 * The front-end hooks live in default-filters.php, which loads before
238 * `init`, so removing them here works. The admin ones are added by
239 * wp-admin/includes/admin-filters.php, which loads after `init`; they
240 * are removed on `admin_init` instead. wp_enqueue_emoji_styles is the
241 * WP 6.4+ path; print_emoji_styles is kept for older cores.
242 */
243 public static function disable_emojis(): void {
244 remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
245 remove_action( 'embed_head', 'print_emoji_detection_script' );
246 remove_action( 'wp_enqueue_scripts', 'wp_enqueue_emoji_styles' );
247 remove_action( 'enqueue_embed_scripts', 'wp_enqueue_emoji_styles' );
248 remove_action( 'wp_print_styles', 'print_emoji_styles' );
249 remove_filter( 'the_content_feed', 'wp_staticize_emoji' );
250 remove_filter( 'comment_text_rss', 'wp_staticize_emoji' );
251 remove_filter( 'wp_mail', 'wp_staticize_emoji_for_email' );
252 add_filter( 'tiny_mce_plugins', array( __CLASS__, 'strip_tinymce_emoji' ) );
253 add_action( 'admin_init', array( __CLASS__, 'disable_admin_emojis' ) );
254 }
255
256 public static function disable_admin_emojis(): void {
257 remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
258 remove_action( 'admin_enqueue_scripts', 'wp_enqueue_emoji_styles' );
259 remove_action( 'admin_print_styles', 'print_emoji_styles' );
260 }
261
262 /**
263 * @param mixed $plugins
264 * @return mixed
265 */
266 public static function strip_tinymce_emoji( $plugins ) {
267 return is_array( $plugins ) ? array_values( array_diff( $plugins, array( 'wpemoji' ) ) ) : $plugins;
268 }
269
270 public static function disable_oembed(): void {
271 // Strip discovery <link> from <head>.
272 remove_action( 'wp_head', 'wp_oembed_add_discovery_links' );
273 remove_action( 'wp_head', 'wp_oembed_add_host_js' );
274 // Drop the auto-embed filter (paste-a-URL-becomes-embed).
275 remove_filter( 'the_content', array( $GLOBALS['wp_embed'] ?? null, 'autoembed' ), 8 );
276 // Drop wp-embed.min.js + the rewrite rule.
277 add_action(
278 'wp_footer',
279 static function () {
280 wp_dequeue_script( 'wp-embed' );
281 },
282 1
283 );
284 add_filter(
285 'rewrite_rules_array',
286 static function ( $rules ) {
287 if ( ! is_array( $rules ) ) {
288 return $rules;
289 }
290 foreach ( $rules as $rule => $rewrite ) {
291 if ( false !== strpos( (string) $rewrite, 'embed=true' ) ) {
292 unset( $rules[ $rule ] );
293 }
294 }
295 return $rules;
296 }
297 );
298 }
299
300 /**
301 * Editor-only style handles that have no business on an anonymous
302 * frontend page. Deliberately NOT wp-block-library /
303 * wp-block-library-theme / global-styles — those style the blocks
304 * visitors actually see. Observed live: a plugin pulled wp-editor +
305 * wp-components (and their deps) onto a marketing homepage, several
306 * hundred KB of render-blocking CSS nothing on the page used.
307 */
308 private const EDITOR_STYLE_HANDLES = array(
309 'wp-editor',
310 'wp-block-editor',
311 'wp-block-directory',
312 'wp-components',
313 'wp-preferences',
314 'wp-media-utils',
315 'wp-reusable-blocks',
316 'wp-patterns',
317 'wp-edit-blocks',
318 'wp-edit-post',
319 'wp-edit-site',
320 'wp-edit-widgets',
321 'wp-format-library',
322 'wp-list-reusable-blocks',
323 'wp-nux',
324 );
325
326 public static function dequeue_editor_styles(): void {
327 // Logged-in views legitimately reach editor surfaces (front-end
328 // editing, admin bar flows), and a builder editing screen is a
329 // front-end URL — same guard set as the other frontend strips.
330 if ( is_user_logged_in() || is_admin() || \XSpeed\Builder_Editor::is_active() ) {
331 return;
332 }
333 $styles = wp_styles();
334 foreach ( self::EDITOR_STYLE_HANDLES as $handle ) {
335 wp_dequeue_style( $handle );
336 }
337 // Dequeue alone is not enough: dependencies are resolved again at
338 // print time, so any queued sheet that lists one of these as a dep
339 // pulls it straight back. Strip the handles from every registered
340 // sheet's deps too — same technique strip_jquery_migrate() uses.
341 foreach ( $styles->registered as $dependency ) {
342 if ( is_array( $dependency->deps ?? null ) && array_intersect( $dependency->deps, self::EDITOR_STYLE_HANDLES ) ) {
343 $dependency->deps = array_values( array_diff( $dependency->deps, self::EDITOR_STYLE_HANDLES ) );
344 }
345 }
346 }
347
348 /**
349 * `pre_ping` passes the link list by reference.
350 *
351 * @param array $links
352 */
353 public static function strip_self_pings( &$links ): void {
354 if ( ! is_array( $links ) ) {
355 return;
356 }
357 $links = array_values(
358 array_filter(
359 $links,
360 static function ( $link ): bool {
361 return ! self::is_own_url( (string) $link );
362 }
363 )
364 );
365 }
366
367 /**
368 * Same host (any scheme, any case, with or without www.) and a path
369 * inside the home path. A plain prefix check missed http:// links on
370 * an https site, which migrated sites still carry, and matched
371 * example.test.evil.test as home.
372 */
373 public static function is_own_url( string $url ): bool {
374 $home_parts = wp_parse_url( (string) home_url() );
375 $parts = wp_parse_url( $url );
376 if ( ! is_array( $home_parts ) || ! is_array( $parts ) || empty( $parts['host'] ) || empty( $home_parts['host'] ) ) {
377 return false;
378 }
379 $strip = static function ( string $host ): string {
380 $host = strtolower( $host );
381 return 0 === strpos( $host, 'www.' ) ? substr( $host, 4 ) : $host;
382 };
383 if ( $strip( $parts['host'] ) !== $strip( $home_parts['host'] ) ) {
384 return false;
385 }
386 $home_path = rtrim( (string) ( $home_parts['path'] ?? '' ), '/' );
387 $path = (string) ( $parts['path'] ?? '' );
388 return '' === $home_path || $path === $home_path || 0 === strpos( $path, $home_path . '/' );
389 }
390
391 public static function block_feed(): void {
392 wp_die(
393 esc_html__( 'Feeds are disabled.', 'xspeed' ),
394 '',
395 array( 'response' => 404 )
396 );
397 }
398
399 /**
400 * @param array $headers
401 * @return array
402 */
403 public static function strip_xmlrpc_header( $headers ) {
404 if ( is_array( $headers ) ) {
405 unset( $headers['X-Pingback'] );
406 }
407 return $headers;
408 }
409
410 /**
411 * @param \WP_Scripts $scripts
412 */
413 public static function strip_jquery_migrate( $scripts ): void {
414 // Builders and their add-ons still rely on jQuery Migrate shims; a
415 // builder editing screen is a front-end URL, so is_admin() misses it
416 // and the editor loses methods it calls. (#281)
417 if ( is_admin() || \XSpeed\Builder_Editor::is_active() || ! isset( $scripts->registered['jquery'] ) ) {
418 return;
419 }
420 $jquery = $scripts->registered['jquery'];
421 if ( is_array( $jquery->deps ?? null ) ) {
422 $jquery->deps = array_values( array_diff( $jquery->deps, array( 'jquery-migrate' ) ) );
423 }
424 }
425
426 /**
427 * strip_jquery_migrate() on the registry that exists now, for a request
428 * where `wp_default_scripts` fired before this module hooked it. (#587)
429 */
430 public static function strip_jquery_migrate_now(): void {
431 self::strip_jquery_migrate( wp_scripts() );
432 }
433
434 /**
435 * Block anonymous /wp-json/ access. Logged-in users + already-errored
436 * requests pass through untouched.
437 *
438 * @param \WP_Error|null|true $result
439 * @return \WP_Error|null|true
440 */
441 public static function restrict_rest( $result ) {
442 if ( ! empty( $result ) ) {
443 return $result; // upstream auth already decided.
444 }
445 if ( is_user_logged_in() ) {
446 return $result;
447 }
448 return new \WP_Error(
449 'rest_forbidden_anonymous',
450 __( 'Anonymous REST access is disabled on this site.', 'xspeed' ),
451 array( 'status' => 401 )
452 );
453 }
454
455 public function cli_commands(): array {
456 return array(
457 array(
458 'name' => 'xspeed bloat',
459 'callback' => array( $this, 'cli_handler' ),
460 'shortdesc' => 'Show which bloat-removal toggles are active.',
461 'ai_hint' => 'What unnecessary WordPress output is being stripped (emojis, embeds, jQuery Migrate, dashicons)? Use when asked why extra scripts still load on the frontend, or before recommending bloat removal.',
462 'synopsis' => array(),
463 ),
464 );
465 }
466
467 public function cli_handler( array $args, array $assoc ): void {
468 $opts = Settings_Manager::get( self::SLUG );
469 foreach ( $opts as $key => $value ) {
470 \WP_CLI::log( sprintf( '%-30s %s', $key, $value ? 'on' : 'off' ) );
471 }
472 }
473
474 /**
475 * Bloat has no master switch -- it is on when any of its boolean
476 * flags is set. (#363)
477 */
478 public function is_active(): ?bool {
479 return $this->any_bool_flag_on();
480 }
481 }
482