PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.6
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 1.1.5 All 32 releases
xspeed / includes / modules / Bloat / BloatModule.php

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

461 lines 17.2 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' => __( 'Turn off WordPress defaults you do not use — saves bytes, requests, and attack surface.', 'xspeed' ),
41 );
42 }
43
44 public function settings_schema(): array {
45 return array(
46 'disable_emojis' => array(
47 'type' => 'bool',
48 'default' => false,
49 'label' => __( 'Disable Emojis', 'xspeed' ),
50 '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' ),
51 ),
52 'disable_dashicons_frontend' => array(
53 'type' => 'bool',
54 'default' => false,
55 'label' => __( 'Disable Dashicons on Frontend', 'xspeed' ),
56 'description' => __( 'Drop the dashicons stylesheet from non-admin pages. Most themes do not need it. Saves ~45 KB per visitor.', 'xspeed' ),
57 ),
58 'disable_oembed' => array(
59 'type' => 'bool',
60 'default' => false,
61 'label' => __( 'Disable oEmbed Discovery + wp-embed.min.js', 'xspeed' ),
62 'description' => __( 'Strip the auto-embed handlers + the embed script. Posts that paste a YouTube URL will no longer auto-render the player — embed it via a block instead. Saves a request per page.', 'xspeed' ),
63 ),
64 'disable_rss_feeds' => array(
65 'type' => 'bool',
66 'default' => false,
67 'label' => __( 'Disable RSS Feeds', 'xspeed' ),
68 'description' => __( 'Return a 404 on /feed/ and similar endpoints. Useful for sites that do not publish feeds and want to cut feed-fetcher traffic.', 'xspeed' ),
69 ),
70 'disable_xmlrpc' => array(
71 'type' => 'bool',
72 'default' => false,
73 'label' => __( 'Disable XML-RPC', 'xspeed' ),
74 'description' => __( 'Disable the legacy xmlrpc.php endpoint. Cuts pingback brute-force noise; safe to disable unless you use a remote WP client (Jetpack, WordPress mobile app).', 'xspeed' ),
75 ),
76 'strip_jquery_migrate' => array(
77 'type' => 'bool',
78 'default' => false,
79 'label' => __( 'Strip jQuery Migrate on Frontend', 'xspeed' ),
80 'description' => __( 'Remove the jquery-migrate compatibility shim from non-admin pages. Saves ~10 KB; safe on modern themes / plugins.', 'xspeed' ),
81 ),
82 'strip_editor_styles' => array(
83 'type' => 'bool',
84 'default' => false,
85 'label' => __( 'Strip Block-Editor Styles on Frontend', 'xspeed' ),
86 'description' => __( 'Drop editor-only stylesheets (wp-editor, wp-components, and friends) from anonymous pages. A plugin that enqueues them on the frontend usually does so by accident — they can add hundreds of KB of render-blocking CSS. Frontend block styles (wp-block-library) are never touched.', 'xspeed' ),
87 ),
88 'remove_rsd_link' => array(
89 'type' => 'bool',
90 'default' => false,
91 'label' => __( 'Remove RSD Link', 'xspeed' ),
92 'description' => __( 'Drop the Really Simple Discovery link from the page head. Only old desktop blogging clients read it.', 'xspeed' ),
93 ),
94 'remove_shortlink' => array(
95 'type' => 'bool',
96 'default' => false,
97 'label' => __( 'Remove Shortlink', 'xspeed' ),
98 'description' => __( 'Drop the ?p=123 shortlink tag and header from posts and pages. The shortlinks keep working; they are just no longer advertised.', 'xspeed' ),
99 ),
100 'remove_rest_api_links' => array(
101 'type' => 'bool',
102 'default' => false,
103 'label' => __( 'Remove REST API Links', 'xspeed' ),
104 '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' ),
105 ),
106 'hide_wp_version' => array(
107 'type' => 'bool',
108 'default' => false,
109 'label' => __( 'Hide WordPress Version', 'xspeed' ),
110 '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' ),
111 ),
112 'disable_self_pingbacks' => array(
113 'type' => 'bool',
114 'default' => false,
115 'label' => __( 'Disable Self-Pingbacks', 'xspeed' ),
116 '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' ),
117 ),
118 'restrict_rest_to_authed' => array(
119 'type' => 'bool',
120 'default' => false,
121 'label' => __( 'Restrict REST API to Logged-In Users', 'xspeed' ),
122 'description' => __( 'Block /wp-json/ for anonymous requests. WooCommerce checkout, contact-form submissions, and many block-editor previews need anonymous REST — keep this off unless you know your site does not depend on it.', 'xspeed' ),
123 ),
124 );
125 }
126
127 public function boot(): void {
128 /*
129 * Deferred to `init` priority 0. This reads the module's settings,
130 * which builds settings_schema(), whose labels go through __(), and
131 * boot() runs on `plugins_loaded` — before `after_setup_theme`, the
132 * earliest point WordPress 6.7+ treats as safe to translate.
133 *
134 * Priority 0 (not the default 10) because the body itself registers
135 * an `init` callback at priority 9: adding a hook to the action that
136 * is currently running only takes effect if the new priority is still
137 * ahead of the running position, so we have to be first. Every other
138 * hook it registers fires later than `init`.
139 */
140 add_action( 'init', array( $this, 'boot_on_init' ), 0 );
141 }
142
143 /**
144 * The real boot body — see boot() for why it runs on `init`.
145 */
146 public function boot_on_init(): void {
147 $opts = Settings_Manager::get( self::SLUG );
148
149 if ( ! empty( $opts['disable_emojis'] ) ) {
150 self::disable_emojis();
151 }
152
153 if ( ! empty( $opts['disable_dashicons_frontend'] ) ) {
154 add_action( 'wp_enqueue_scripts', array( __CLASS__, 'dequeue_dashicons' ), 100 );
155 }
156
157 if ( ! empty( $opts['disable_oembed'] ) ) {
158 add_action( 'init', array( __CLASS__, 'disable_oembed' ), 9 );
159 }
160
161 if ( ! empty( $opts['disable_rss_feeds'] ) ) {
162 add_action( 'do_feed', array( __CLASS__, 'block_feed' ), 1 );
163 add_action( 'do_feed_rdf', array( __CLASS__, 'block_feed' ), 1 );
164 add_action( 'do_feed_rss', array( __CLASS__, 'block_feed' ), 1 );
165 add_action( 'do_feed_rss2', array( __CLASS__, 'block_feed' ), 1 );
166 add_action( 'do_feed_atom', array( __CLASS__, 'block_feed' ), 1 );
167 add_action( 'do_feed_rss2_comments', array( __CLASS__, 'block_feed' ), 1 );
168 add_action( 'do_feed_atom_comments', array( __CLASS__, 'block_feed' ), 1 );
169 }
170
171 if ( ! empty( $opts['disable_xmlrpc'] ) ) {
172 add_filter( 'xmlrpc_enabled', '__return_false' );
173 add_filter( 'wp_headers', array( __CLASS__, 'strip_xmlrpc_header' ) );
174 add_filter( 'pings_open', '__return_false' );
175 }
176
177 if ( ! empty( $opts['strip_jquery_migrate'] ) ) {
178 add_action( 'wp_default_scripts', array( __CLASS__, 'strip_jquery_migrate' ) );
179 }
180
181 if ( ! empty( $opts['strip_editor_styles'] ) ) {
182 // Late, so anything enqueued at normal priority is already queued.
183 add_action( 'wp_enqueue_scripts', array( __CLASS__, 'dequeue_editor_styles' ), PHP_INT_MAX );
184 }
185
186 if ( ! empty( $opts['remove_rsd_link'] ) ) {
187 remove_action( 'wp_head', 'rsd_link' );
188 }
189
190 if ( ! empty( $opts['remove_shortlink'] ) ) {
191 remove_action( 'wp_head', 'wp_shortlink_wp_head' );
192 remove_action( 'template_redirect', 'wp_shortlink_header', 11 );
193 }
194
195 if ( ! empty( $opts['remove_rest_api_links'] ) ) {
196 remove_action( 'wp_head', 'rest_output_link_wp_head' );
197 remove_action( 'template_redirect', 'rest_output_link_header', 11 );
198 remove_action( 'xmlrpc_rsd_apis', 'rest_output_rsd' );
199 }
200
201 if ( ! empty( $opts['hide_wp_version'] ) ) {
202 remove_action( 'wp_head', 'wp_generator' );
203 add_filter( 'the_generator', '__return_empty_string' );
204 }
205
206 if ( ! empty( $opts['disable_self_pingbacks'] ) ) {
207 add_action( 'pre_ping', array( __CLASS__, 'strip_self_pings' ) );
208 }
209
210 if ( ! empty( $opts['restrict_rest_to_authed'] ) ) {
211 add_filter( 'rest_authentication_errors', array( __CLASS__, 'restrict_rest' ) );
212 }
213 }
214
215 public static function dequeue_dashicons(): void {
216 if ( is_admin_bar_showing() || is_user_logged_in() ) {
217 return; // the admin bar uses dashicons; only strip on truly anonymous pages.
218 }
219 wp_dequeue_style( 'dashicons' );
220 wp_deregister_style( 'dashicons' );
221 }
222
223 /**
224 * The front-end hooks live in default-filters.php, which loads before
225 * `init`, so removing them here works. The admin ones are added by
226 * wp-admin/includes/admin-filters.php, which loads after `init`; they
227 * are removed on `admin_init` instead. wp_enqueue_emoji_styles is the
228 * WP 6.4+ path; print_emoji_styles is kept for older cores.
229 */
230 public static function disable_emojis(): void {
231 remove_action( 'wp_head', 'print_emoji_detection_script', 7 );
232 remove_action( 'embed_head', 'print_emoji_detection_script' );
233 remove_action( 'wp_enqueue_scripts', 'wp_enqueue_emoji_styles' );
234 remove_action( 'enqueue_embed_scripts', 'wp_enqueue_emoji_styles' );
235 remove_action( 'wp_print_styles', 'print_emoji_styles' );
236 remove_filter( 'the_content_feed', 'wp_staticize_emoji' );
237 remove_filter( 'comment_text_rss', 'wp_staticize_emoji' );
238 remove_filter( 'wp_mail', 'wp_staticize_emoji_for_email' );
239 add_filter( 'tiny_mce_plugins', array( __CLASS__, 'strip_tinymce_emoji' ) );
240 add_action( 'admin_init', array( __CLASS__, 'disable_admin_emojis' ) );
241 }
242
243 public static function disable_admin_emojis(): void {
244 remove_action( 'admin_print_scripts', 'print_emoji_detection_script' );
245 remove_action( 'admin_enqueue_scripts', 'wp_enqueue_emoji_styles' );
246 remove_action( 'admin_print_styles', 'print_emoji_styles' );
247 }
248
249 /**
250 * @param mixed $plugins
251 * @return mixed
252 */
253 public static function strip_tinymce_emoji( $plugins ) {
254 return is_array( $plugins ) ? array_values( array_diff( $plugins, array( 'wpemoji' ) ) ) : $plugins;
255 }
256
257 public static function disable_oembed(): void {
258 // Strip discovery <link> from <head>.
259 remove_action( 'wp_head', 'wp_oembed_add_discovery_links' );
260 remove_action( 'wp_head', 'wp_oembed_add_host_js' );
261 // Drop the auto-embed filter (paste-a-URL-becomes-embed).
262 remove_filter( 'the_content', array( $GLOBALS['wp_embed'] ?? null, 'autoembed' ), 8 );
263 // Drop wp-embed.min.js + the rewrite rule.
264 add_action(
265 'wp_footer',
266 static function () {
267 wp_dequeue_script( 'wp-embed' );
268 },
269 1
270 );
271 add_filter(
272 'rewrite_rules_array',
273 static function ( $rules ) {
274 if ( ! is_array( $rules ) ) {
275 return $rules;
276 }
277 foreach ( $rules as $rule => $rewrite ) {
278 if ( false !== strpos( (string) $rewrite, 'embed=true' ) ) {
279 unset( $rules[ $rule ] );
280 }
281 }
282 return $rules;
283 }
284 );
285 }
286
287 /**
288 * Editor-only style handles that have no business on an anonymous
289 * frontend page. Deliberately NOT wp-block-library /
290 * wp-block-library-theme / global-styles — those style the blocks
291 * visitors actually see. Observed live: a plugin pulled wp-editor +
292 * wp-components (and their deps) onto a marketing homepage, several
293 * hundred KB of render-blocking CSS nothing on the page used.
294 */
295 private const EDITOR_STYLE_HANDLES = array(
296 'wp-editor',
297 'wp-block-editor',
298 'wp-block-directory',
299 'wp-components',
300 'wp-preferences',
301 'wp-media-utils',
302 'wp-reusable-blocks',
303 'wp-patterns',
304 'wp-edit-blocks',
305 'wp-edit-post',
306 'wp-edit-site',
307 'wp-edit-widgets',
308 'wp-format-library',
309 'wp-list-reusable-blocks',
310 'wp-nux',
311 );
312
313 public static function dequeue_editor_styles(): void {
314 // Logged-in views legitimately reach editor surfaces (front-end
315 // editing, admin bar flows), and a builder editing screen is a
316 // front-end URL — same guard set as the other frontend strips.
317 if ( is_user_logged_in() || is_admin() || \XSpeed\Builder_Editor::is_active() ) {
318 return;
319 }
320 $styles = wp_styles();
321 foreach ( self::EDITOR_STYLE_HANDLES as $handle ) {
322 wp_dequeue_style( $handle );
323 }
324 // Dequeue alone is not enough: dependencies are resolved again at
325 // print time, so any queued sheet that lists one of these as a dep
326 // pulls it straight back. Strip the handles from every registered
327 // sheet's deps too — same technique strip_jquery_migrate() uses.
328 foreach ( $styles->registered as $dependency ) {
329 if ( is_array( $dependency->deps ?? null ) && array_intersect( $dependency->deps, self::EDITOR_STYLE_HANDLES ) ) {
330 $dependency->deps = array_values( array_diff( $dependency->deps, self::EDITOR_STYLE_HANDLES ) );
331 }
332 }
333 }
334
335 /**
336 * `pre_ping` passes the link list by reference.
337 *
338 * @param array $links
339 */
340 public static function strip_self_pings( &$links ): void {
341 if ( ! is_array( $links ) ) {
342 return;
343 }
344 $links = array_values(
345 array_filter(
346 $links,
347 static function ( $link ): bool {
348 return ! self::is_own_url( (string) $link );
349 }
350 )
351 );
352 }
353
354 /**
355 * Same host (any scheme, any case, with or without www.) and a path
356 * inside the home path. A plain prefix check missed http:// links on
357 * an https site, which migrated sites still carry, and matched
358 * example.test.evil.test as home.
359 */
360 public static function is_own_url( string $url ): bool {
361 $home_parts = wp_parse_url( (string) home_url() );
362 $parts = wp_parse_url( $url );
363 if ( ! is_array( $home_parts ) || ! is_array( $parts ) || empty( $parts['host'] ) || empty( $home_parts['host'] ) ) {
364 return false;
365 }
366 $strip = static function ( string $host ): string {
367 $host = strtolower( $host );
368 return 0 === strpos( $host, 'www.' ) ? substr( $host, 4 ) : $host;
369 };
370 if ( $strip( $parts['host'] ) !== $strip( $home_parts['host'] ) ) {
371 return false;
372 }
373 $home_path = rtrim( (string) ( $home_parts['path'] ?? '' ), '/' );
374 $path = (string) ( $parts['path'] ?? '' );
375 return '' === $home_path || $path === $home_path || 0 === strpos( $path, $home_path . '/' );
376 }
377
378 public static function block_feed(): void {
379 wp_die(
380 esc_html__( 'Feeds are disabled.', 'xspeed' ),
381 '',
382 array( 'response' => 404 )
383 );
384 }
385
386 /**
387 * @param array $headers
388 * @return array
389 */
390 public static function strip_xmlrpc_header( $headers ) {
391 if ( is_array( $headers ) ) {
392 unset( $headers['X-Pingback'] );
393 }
394 return $headers;
395 }
396
397 /**
398 * @param \WP_Scripts $scripts
399 */
400 public static function strip_jquery_migrate( $scripts ): void {
401 // Builders and their add-ons still rely on jQuery Migrate shims; a
402 // builder editing screen is a front-end URL, so is_admin() misses it
403 // and the editor loses methods it calls. (#281)
404 if ( is_admin() || \XSpeed\Builder_Editor::is_active() || ! isset( $scripts->registered['jquery'] ) ) {
405 return;
406 }
407 $jquery = $scripts->registered['jquery'];
408 if ( is_array( $jquery->deps ?? null ) ) {
409 $jquery->deps = array_values( array_diff( $jquery->deps, array( 'jquery-migrate' ) ) );
410 }
411 }
412
413 /**
414 * Block anonymous /wp-json/ access. Logged-in users + already-errored
415 * requests pass through untouched.
416 *
417 * @param \WP_Error|null|true $result
418 * @return \WP_Error|null|true
419 */
420 public static function restrict_rest( $result ) {
421 if ( ! empty( $result ) ) {
422 return $result; // upstream auth already decided.
423 }
424 if ( is_user_logged_in() ) {
425 return $result;
426 }
427 return new \WP_Error(
428 'rest_forbidden_anonymous',
429 __( 'Anonymous REST access is disabled on this site.', 'xspeed' ),
430 array( 'status' => 401 )
431 );
432 }
433
434 public function cli_commands(): array {
435 return array(
436 array(
437 'name' => 'xspeed bloat',
438 'callback' => array( $this, 'cli_handler' ),
439 'shortdesc' => 'Show which bloat-removal toggles are active.',
440 '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.',
441 'synopsis' => array(),
442 ),
443 );
444 }
445
446 public function cli_handler( array $args, array $assoc ): void {
447 $opts = Settings_Manager::get( self::SLUG );
448 foreach ( $opts as $key => $value ) {
449 \WP_CLI::log( sprintf( '%-30s %s', $key, $value ? 'on' : 'off' ) );
450 }
451 }
452
453 /**
454 * Bloat has no master switch -- it is on when any of its boolean
455 * flags is set. (#363)
456 */
457 public function is_active(): ?bool {
458 return $this->any_bool_flag_on();
459 }
460 }
461