PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.6
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 / class-admin.php

class-admin.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.0.6, at includes/class-admin.php

458 lines 17.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Admin menu + asset enqueue.
4 *
5 * @package XSpeed
6 */
7
8 namespace XSpeed;
9
10 defined( 'ABSPATH' ) || exit;
11
12 class Admin {
13
14 const PAGE_SLUG = 'xspeed';
15
16 const THEME_COOKIE = 'xspeed_theme';
17
18 public function __construct() {
19 add_action( 'admin_menu', array( $this, 'register_menu' ) );
20 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) );
21 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_menu_styles' ) );
22 add_filter( 'admin_body_class', array( __CLASS__, 'admin_body_class' ) );
23 // Strip third-party admin notices on our screens only. Fires in
24 // `in_admin_header` (after `get_current_screen()` is populated
25 // but before notices render) so the screen check is reliable.
26 add_action( 'in_admin_header', array( __CLASS__, 'suppress_foreign_notices' ), 0 );
27 }
28
29 /**
30 * Remove every third-party admin notice on xSpeed admin screens so the
31 * dashboard stays visually clean. Scoped via `is_plugin_page()` — runs
32 * nowhere else. Our own notices stay rendered: register them on the
33 * dedicated `xspeed_admin_notices` action below, which fires after
34 * this suppression and is wired to all three core notice hooks.
35 *
36 * Hooks cleared: `admin_notices`, `all_admin_notices`,
37 * `user_admin_notices`, `network_admin_notices`. WordPress's own
38 * settings-saved / updated messages are emitted via `settings_errors()`
39 * and printed inline by `options.php` — they are NOT on these hooks
40 * and are unaffected.
41 *
42 * @return void
43 */
44 public static function suppress_foreign_notices() {
45 if ( ! self::is_plugin_page() ) {
46 return;
47 }
48 remove_all_actions( 'admin_notices' );
49 remove_all_actions( 'all_admin_notices' );
50 remove_all_actions( 'user_admin_notices' );
51 remove_all_actions( 'network_admin_notices' );
52
53 // Re-route the four standard notice hooks to a single namespaced
54 // action so xSpeed (and any deliberate extender that opts in)
55 // keeps a place to emit notices after the strip.
56 $relay = static function () {
57 /**
58 * Fires in place of WP's `admin_notices` family on xSpeed
59 * admin screens. Use this instead of `admin_notices` when
60 * you want a notice to survive xSpeed's third-party
61 * suppression.
62 *
63 * @since 1.0.3
64 */
65 do_action( 'xspeed_admin_notices' );
66 };
67 add_action( 'admin_notices', $relay );
68 add_action( 'all_admin_notices', $relay );
69 add_action( 'user_admin_notices', $relay );
70 add_action( 'network_admin_notices', $relay );
71 }
72
73 /**
74 * Server-side theme detection from the cookie written by useTheme. Used
75 * to emit the `.dark` class on the React mount node and the
76 * `xspeed-dark` class on `<body>` during the initial render — kills the
77 * light→dark flash that happens when JS adds those classes after the
78 * page has already painted.
79 *
80 * @return string 'dark' | 'light'
81 */
82 public static function user_theme() {
83 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only string compare; value is sanitized via sanitize_key() on the next line before any use.
84 $raw = isset( $_COOKIE[ self::THEME_COOKIE ] ) ? wp_unslash( $_COOKIE[ self::THEME_COOKIE ] ) : '';
85 return 'dark' === sanitize_key( $raw ) ? 'dark' : 'light';
86 }
87
88 public static function is_plugin_page() {
89 // Prefer the ?page= slug: it's brand-independent. WordPress derives
90 // the submenu screen base from the *sanitized parent menu title*, so
91 // once White-Label renames the menu the base becomes e.g.
92 // "acmespeed_page_xspeed-onboarding" and any check anchored on
93 // self::PAGE_SLUG ("xspeed_page_…") silently stops matching — which
94 // dropped the xspeed-page / xspeed-dark body classes on the wizard and
95 // left it unstyled. Match the slug instead. (FBS-82222)
96 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen gate.
97 if ( self::PAGE_SLUG === $page || Onboarding::PAGE_SLUG === $page ) {
98 return true;
99 }
100
101 // Fallback for contexts where $_GET['page'] isn't set but the screen is
102 // available. The toplevel base is slug-based (stable); the submenu base
103 // is title-derived, so match on its slug SUFFIX rather than the prefix.
104 $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
105 if ( ! $screen ) {
106 return false;
107 }
108 $base = (string) $screen->base;
109 return 0 === strpos( $base, 'toplevel_page_' . self::PAGE_SLUG )
110 || (bool) preg_match( '/_page_' . preg_quote( self::PAGE_SLUG, '/' ) . '($|-)/', $base );
111 }
112
113 public static function admin_body_class( $classes ) {
114 if ( ! self::is_plugin_page() ) {
115 return $classes;
116 }
117 $classes .= ' xspeed-page';
118 if ( 'dark' === self::user_theme() ) {
119 $classes .= ' xspeed-dark';
120 }
121 return $classes;
122 }
123
124 public function register_menu() {
125 $brand = self::branding();
126 // Use the white-label logo for the admin menu icon when set, so the
127 // rebrand carries to the menu mark too — not just the title. Falls
128 // back to the built-in xSpeed SVG. (FBS-82222)
129 $menu_icon = ! empty( $brand['logo_svg'] ) ? $brand['logo_svg'] : self::menu_icon();
130 add_menu_page(
131 $brand['name'],
132 $brand['name'],
133 'manage_options',
134 self::PAGE_SLUG,
135 array( $this, 'render' ),
136 $menu_icon,
137 80
138 );
139
140 // Drop WP's auto-generated duplicate first submenu (which
141 // inherits the toplevel "xSpeed" title). The group deep-links
142 // below replace it — keeping it would render a redundant
143 // "xSpeed" / "Dashboard" row that just re-links to the same
144 // page as the toplevel entry.
145 global $submenu;
146 // $submenu may not yet be populated for this slug; the
147 // `remove_submenu_page` call covers either case.
148 remove_submenu_page( self::PAGE_SLUG, self::PAGE_SLUG );
149
150 // Deep-link submenus — each points to the same dashboard page
151 // with a section hash so React's App.tsx routing lands the
152 // user inside the right module. WordPress's add_submenu_page
153 // strips the hash, so we inject directly into $submenu where
154 // it survives intact (the same trick Yoast / WooCommerce use
155 // for their per-area shortcuts).
156 global $submenu;
157 $deep_links = self::deep_link_items();
158 foreach ( $deep_links as $hash => $label ) {
159 $submenu[ self::PAGE_SLUG ][] = array(
160 $label,
161 'manage_options',
162 'admin.php?page=' . self::PAGE_SLUG . '#' . $hash,
163 );
164 }
165 }
166
167 /**
168 * Section deep-links rendered under the xSpeed menu. Mirrors the
169 * React sidebar's group manifest (see src/components/sidebarGroups.ts)
170 * so the WP admin rail reads as the same map as the in-app sidebar.
171 * Each entry points at the first module slug in that group; the
172 * React app's hash router lands the user on that module, which is
173 * the first row of that group's sidebar section — no manual scrolling.
174 *
175 * If you add a group to React's SIDEBAR_GROUPS, add it here too. The
176 * two arrays are intentionally co-located in PR review (same change
177 * touches both) rather than DRY'd through a generated config file —
178 * this is the only PHP↔TS coupling and it's tiny.
179 *
180 * @return array<string,string> map of first-module-hash → group label.
181 */
182 private static function deep_link_items() {
183 // Mirrors the dashboard sidebar groups (SIDEBAR_GROUPS in
184 // sidebarGroups.ts) 1:1, in the same order. Each key is the first
185 // module slug of that group (the anchor the submenu deep-links to).
186 // Keep these two lists in sync — they're the only PHP↔TS coupling.
187 // Anchors must be slugs that are always present in the /modules
188 // payload so the hash resolves on Free too: 'ai-provider' (AI group;
189 // injected as a locked placeholder on Free via the upsell manifest)
190 // and 'multisite' (Pro Add-ons; resolves to the real module on a
191 // licensed multisite). NOTE: must match a slug React actually
192 // renders — 'ai-privacy' was removed from the groups, so anchoring
193 // AI there opened nothing (FBS-82743 #2). (FBS-82096)
194 return array(
195 'cache' => __( 'Cache', 'xspeed' ),
196 'minify' => __( 'Performance', 'xspeed' ),
197 'cdn' => __( 'Network', 'xspeed' ),
198 'health' => __( 'Insights', 'xspeed' ),
199 'database' => __( 'Tools', 'xspeed' ),
200 'ai-provider' => __( 'AI', 'xspeed' ),
201 'multisite' => __( 'Pro Add-ons', 'xspeed' ),
202 );
203 }
204
205 /**
206 * Pluggable branding for the dashboard chrome. xspeed-pro's
207 * White-Label module hooks `xspeed_branding` to override these
208 * values from saved settings.
209 *
210 * @return array{name:string,footer_credit:?string,hide_help_links:bool,logo_svg:?string}
211 * @since 1.5.0
212 */
213 public static function branding() {
214 $defaults = array(
215 'name' => 'xSpeed',
216 'footer_credit' => null, // null = show the default WPDeveloper credit.
217 'hide_help_links' => false,
218 'logo_svg' => null, // null = use the built-in brand mark.
219 );
220 $out = apply_filters( 'xspeed_branding', $defaults );
221 if ( ! is_array( $out ) ) {
222 return $defaults;
223 }
224 return array_merge( $defaults, $out );
225 }
226
227 /**
228 * URL of the SVG menu icon — the official xSpeed brand mark. Uses
229 * fill="currentColor" which renders black in <img> context; the inline
230 * style below recolors it via CSS filter for the WP admin menu states.
231 */
232 private static function menu_icon() {
233 return XSPEED_URL . 'assets/icon.svg';
234 }
235
236 public function render() {
237 $dark = 'dark' === self::user_theme() ? ' dark' : '';
238 printf( '<div id="xspeed-app" class="xspeed-root%s"></div>', esc_attr( $dark ) );
239 }
240
241 /**
242 * Enqueue the stylesheet that recolors the menu icon to match the WP
243 * admin color scheme. Loads on every admin page (not just the plugin's
244 * page) because the menu icon is visible site-wide.
245 */
246 public function enqueue_menu_styles() {
247 wp_enqueue_style(
248 'xspeed-menu-icon',
249 XSPEED_URL . 'assets/menu-icon.css',
250 array(),
251 XSPEED_VERSION
252 );
253
254 // menu-icon.css recolors the built-in mark (fill=currentColor) to white
255 // via a brightness/invert filter. A white-label logo is a real image
256 // (often colored), so cancel the filter for it — otherwise the agency
257 // logo renders as a white silhouette. (FBS-82222)
258 $brand = self::branding();
259 if ( ! empty( $brand['logo_svg'] ) ) {
260 wp_add_inline_style(
261 'xspeed-menu-icon',
262 '#toplevel_page_' . self::PAGE_SLUG . ' .wp-menu-image img{filter:none;opacity:1}'
263 );
264 }
265 }
266
267 public function enqueue( $hook ) {
268 if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) {
269 return;
270 }
271
272 $asset_js = XSPEED_DIR . 'assets/admin.js';
273 $asset_css = XSPEED_DIR . 'assets/admin.css';
274
275 // Needed for the media-library picker used by schema 'media' fields
276 // (e.g. the white-label brand logo). Loads window.wp.media.
277 wp_enqueue_media();
278
279 if ( file_exists( $asset_js ) ) {
280 // filemtime() cache-busts on every rebuild so a stable
281 // VERSION constant never serves stale JS through browser
282 // caches.
283 wp_enqueue_script(
284 'xspeed-admin',
285 XSPEED_URL . 'assets/admin.js',
286 array( 'wp-api-fetch', 'wp-i18n' ),
287 XSPEED_VERSION . '.' . filemtime( $asset_js ),
288 true
289 );
290 // Loads .mo files for the 'xspeed' text-domain into
291 // window.wp.i18n so the React `__()` helper resolves.
292 if ( function_exists( 'wp_set_script_translations' ) ) {
293 wp_set_script_translations(
294 'xspeed-admin',
295 'xspeed',
296 XSPEED_DIR . 'languages'
297 );
298 }
299 }
300
301 if ( file_exists( $asset_css ) ) {
302 wp_enqueue_style(
303 'xspeed-admin',
304 XSPEED_URL . 'assets/admin.css',
305 array(),
306 XSPEED_VERSION . '.' . filemtime( $asset_css )
307 );
308 }
309
310 /**
311 * Fires after the Free dashboard bundle is enqueued, before its
312 * config is localized. Pro hooks this to enqueue its own bundle
313 * with `xspeed-admin` as a dependency, so its panel
314 * registrations run after `window.XSpeedPro` is installed by
315 * Free's main.tsx.
316 *
317 * @since 1.5.0
318 */
319 do_action( 'xspeed_admin_enqueue', $hook );
320
321 wp_localize_script(
322 'xspeed-admin',
323 'XSpeedConfig',
324 array(
325 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
326 'nonce' => wp_create_nonce( 'wp_rest' ),
327 'version' => XSPEED_VERSION,
328 'branding' => self::branding(),
329 // Site's UTC offset in seconds — so datetime pickers (e.g. the
330 // Pro prewarm scheduler) align with WP's own post-scheduling,
331 // which is site-time, not the admin's browser-local time.
332 'gmtOffset' => (int) round( (float) get_option( 'gmt_offset', 0 ) * HOUR_IN_SECONDS ),
333 // 'pro' when xspeed-pro is active + speaks our API
334 // version (see Tier_Registry); 'free' otherwise.
335 // 'trial' reserved for future license-server work.
336 'tier' => class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active() ? 'pro' : 'free',
337 'bootstrap' => self::bootstrap_payload(),
338 )
339 );
340 }
341
342 /**
343 * Pre-rendered settings + status payload, baked into the page so the
344 * React app can mount with real values instead of showing a loading state
345 * while it waits for /settings and /status REST calls.
346 */
347 private static function bootstrap_payload() {
348 $opts = Settings::get();
349 $stats = Cache::get_stats();
350 // Static-rewrite probe state shipped to the React side so the
351 // dashboard can show a persistent banner when nginx/Apache
352 // hasn't been wired to bypass PHP yet. Cache-ONLY here (no $allow_probe
353 // arg) so the dashboard bootstrap never makes the loopback HTTP probe
354 // — that could add seconds to every admin page load on hosts that
355 // stall self-requests. The Health tab runs the live probe on demand;
356 // here we just surface whatever it last cached. (FBS-82142)
357 $server_type = Server::type();
358 // LiteSpeed serves hits via the PHP drop-in by design (its .htaccess
359 // can't add the HIT header or log a static hit), so the static-rewrite
360 // probe is N/A there — surfacing it would pop the "PHP fallback" nag
361 // for a setup working as designed. Only nginx + Apache probe.
362 $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE );
363 $rewrite_probe = null;
364 if ( $opts['cache_enabled'] && $rewrite_capable ) {
365 $probe = Cache::probe_static_rewrite();
366 $rewrite_probe = array(
367 'active' => (bool) ( $probe['active'] ?? false ),
368 'server_type' => $server_type,
369 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts
370 'topology' => Server::rewrite_topology(),
371 // A reverse proxy / CDN in front (X-Forwarded-* present) usually
372 // means the request-terminating nginx isn't user-editable on this
373 // host — the banner uses this to switch to honest messaging
374 // instead of dangling a snippet the user can't apply.
375 'behind_proxy' => Server::is_behind_proxy(),
376 );
377 }
378
379 return array(
380 'settings' => $opts,
381 'status' => array(
382 'enabled' => (bool) $opts['cache_enabled'],
383 'stats' => $stats,
384 'server' => array(
385 'type' => $server_type,
386 'gzip_mode' => Server::gzip_mode(),
387 'gzip_active' => Gzip::probe_active(),
388 'nginx_snippet' => Gzip::nginx_snippet(),
389 ),
390 'rewrite_probe' => $rewrite_probe,
391 // One consolidated nginx server-block snippet aggregating
392 // every enabled module's directives (Cache static-rewrite,
393 // BrowserCache headers, GZIP, …). Null on non-nginx hosts
394 // or when no module contributes directives. Replaces the
395 // per-module "paste this snippet" notices.
396 'nginx_server_block' => Cache::full_nginx_server_block(),
397 ),
398 // Registered Modules (Free + Pro). The React app discovers them
399 // here and renders one sidebar item + one panel per module that
400 // declares a settings schema. Hidden modules are filtered.
401 'modules' => self::modules_payload(),
402 );
403 }
404
405 /**
406 * Serialize every available Module for the React dashboard. Each entry
407 * carries enough to render: identity (slug + tier), UI metadata (label,
408 * icon, optional description), current settings, and the typed schema
409 * the panel uses to render controls.
410 *
411 * Modules that declare `hidden => true` in ui_metadata (e.g., engine
412 * modules with no user-facing settings) are skipped.
413 */
414 public static function modules_payload() {
415 if ( ! class_exists( 'XSpeed\\Module_Registry' ) ) {
416 return array();
417 }
418 $out = array();
419 foreach ( Module_Registry::available() as $slug => $module ) {
420 $meta = $module->ui_metadata();
421 if ( ! empty( $meta['hidden'] ) ) {
422 continue;
423 }
424 $schema = $module->settings_schema();
425 $custom_panel = $meta['custom_panel'] ?? null;
426 // Skip only when the module has neither a schema nor a custom
427 // panel — i.e., truly nothing to render in the dashboard.
428 if ( empty( $schema ) && empty( $custom_panel ) ) {
429 continue;
430 }
431 $entry = array(
432 'slug' => $slug,
433 'tier' => $module->tier(),
434 'version' => $module->version(),
435 'label' => $meta['label'] ?? ucfirst( $slug ),
436 'icon' => $meta['icon'] ?? 'Square',
437 'description' => $meta['description'] ?? '',
438 'settings' => Settings_Manager::get( $slug ),
439 'schema' => $schema,
440 'notices' => $module->ui_notices(),
441 'custom_panel' => $meta['custom_panel'] ?? null,
442 );
443
444 /**
445 * Last-mile descriptor filter. Lets Pro (or third-party
446 * extensions) override any field before the module is
447 * shipped to React. Primary use: xspeed-pro hooks this to
448 * swap `custom_panel` to LicenseLockedPanel for Pro modules
449 * when the license is invalid, so unlocked modules stay
450 * visible in the sidebar (good upsell UX) but the panel
451 * shows an activation prompt instead of the real surface.
452 */
453 $out[] = apply_filters( 'xspeed_module_descriptor', $entry, $module );
454 }
455 return $out;
456 }
457 }
458