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

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

468 lines 18.1 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 // Dashboard-only marker (NOT the onboarding wizard, which shares
119 // `xspeed-page` + the same admin.css but must scroll as a normal
120 // centered card). Layout rules that reshape the WP admin chrome — the
121 // sticky content column that pins the fixed-height dashboard while a
122 // tall admin menu scrolls — are scoped to `.xspeed-dashboard` so they
123 // never touch the wizard. Keyed on the ?page= slug (brand-independent).
124 $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen gate.
125 if ( self::PAGE_SLUG === $page ) {
126 $classes .= ' xspeed-dashboard';
127 }
128 if ( 'dark' === self::user_theme() ) {
129 $classes .= ' xspeed-dark';
130 }
131 return $classes;
132 }
133
134 public function register_menu() {
135 $brand = self::branding();
136 // Use the white-label logo for the admin menu icon when set, so the
137 // rebrand carries to the menu mark too — not just the title. Falls
138 // back to the built-in xSpeed SVG. (FBS-82222)
139 $menu_icon = ! empty( $brand['logo_svg'] ) ? $brand['logo_svg'] : self::menu_icon();
140 add_menu_page(
141 $brand['name'],
142 $brand['name'],
143 'manage_options',
144 self::PAGE_SLUG,
145 array( $this, 'render' ),
146 $menu_icon,
147 80
148 );
149
150 // Drop WP's auto-generated duplicate first submenu (which
151 // inherits the toplevel "xSpeed" title). The group deep-links
152 // below replace it — keeping it would render a redundant
153 // "xSpeed" / "Dashboard" row that just re-links to the same
154 // page as the toplevel entry.
155 global $submenu;
156 // $submenu may not yet be populated for this slug; the
157 // `remove_submenu_page` call covers either case.
158 remove_submenu_page( self::PAGE_SLUG, self::PAGE_SLUG );
159
160 // Deep-link submenus — each points to the same dashboard page
161 // with a section hash so React's App.tsx routing lands the
162 // user inside the right module. WordPress's add_submenu_page
163 // strips the hash, so we inject directly into $submenu where
164 // it survives intact (the same trick Yoast / WooCommerce use
165 // for their per-area shortcuts).
166 global $submenu;
167 $deep_links = self::deep_link_items();
168 foreach ( $deep_links as $hash => $label ) {
169 $submenu[ self::PAGE_SLUG ][] = array(
170 $label,
171 'manage_options',
172 'admin.php?page=' . self::PAGE_SLUG . '#' . $hash,
173 );
174 }
175 }
176
177 /**
178 * Section deep-links rendered under the xSpeed menu. Mirrors the
179 * React sidebar's group manifest (see src/components/sidebarGroups.ts)
180 * so the WP admin rail reads as the same map as the in-app sidebar.
181 * Each entry points at the first module slug in that group; the
182 * React app's hash router lands the user on that module, which is
183 * the first row of that group's sidebar section — no manual scrolling.
184 *
185 * If you add a group to React's SIDEBAR_GROUPS, add it here too. The
186 * two arrays are intentionally co-located in PR review (same change
187 * touches both) rather than DRY'd through a generated config file —
188 * this is the only PHP↔TS coupling and it's tiny.
189 *
190 * @return array<string,string> map of first-module-hash → group label.
191 */
192 private static function deep_link_items() {
193 // Mirrors the dashboard sidebar groups (SIDEBAR_GROUPS in
194 // sidebarGroups.ts) 1:1, in the same order. Each key is the first
195 // module slug of that group (the anchor the submenu deep-links to).
196 // Keep these two lists in sync — they're the only PHP↔TS coupling.
197 // Anchors must be slugs that are always present in the /modules
198 // payload so the hash resolves on Free too: 'ai-provider' (AI group;
199 // injected as a locked placeholder on Free via the upsell manifest)
200 // and 'multisite' (Pro Add-ons; resolves to the real module on a
201 // licensed multisite). NOTE: must match a slug React actually
202 // renders — 'ai-privacy' was removed from the groups, so anchoring
203 // AI there opened nothing (FBS-82743 #2). (FBS-82096)
204 return array(
205 'cache' => __( 'Cache', 'xspeed' ),
206 'minify' => __( 'Performance', 'xspeed' ),
207 'cdn' => __( 'Network', 'xspeed' ),
208 'health' => __( 'Insights', 'xspeed' ),
209 'database' => __( 'Tools', 'xspeed' ),
210 'ai-provider' => __( 'AI', 'xspeed' ),
211 'multisite' => __( 'Pro Add-ons', 'xspeed' ),
212 );
213 }
214
215 /**
216 * Pluggable branding for the dashboard chrome. xspeed-pro's
217 * White-Label module hooks `xspeed_branding` to override these
218 * values from saved settings.
219 *
220 * @return array{name:string,footer_credit:?string,hide_help_links:bool,logo_svg:?string}
221 * @since 1.5.0
222 */
223 public static function branding() {
224 $defaults = array(
225 'name' => 'xSpeed',
226 'footer_credit' => null, // null = show the default WPDeveloper credit.
227 'hide_help_links' => false,
228 'logo_svg' => null, // null = use the built-in brand mark.
229 );
230 $out = apply_filters( 'xspeed_branding', $defaults );
231 if ( ! is_array( $out ) ) {
232 return $defaults;
233 }
234 return array_merge( $defaults, $out );
235 }
236
237 /**
238 * URL of the SVG menu icon — the official xSpeed brand mark. Uses
239 * fill="currentColor" which renders black in <img> context; the inline
240 * style below recolors it via CSS filter for the WP admin menu states.
241 */
242 private static function menu_icon() {
243 return XSPEED_URL . 'assets/icon.svg';
244 }
245
246 public function render() {
247 $dark = 'dark' === self::user_theme() ? ' dark' : '';
248 printf( '<div id="xspeed-app" class="xspeed-root%s"></div>', esc_attr( $dark ) );
249 }
250
251 /**
252 * Enqueue the stylesheet that recolors the menu icon to match the WP
253 * admin color scheme. Loads on every admin page (not just the plugin's
254 * page) because the menu icon is visible site-wide.
255 */
256 public function enqueue_menu_styles() {
257 wp_enqueue_style(
258 'xspeed-menu-icon',
259 XSPEED_URL . 'assets/menu-icon.css',
260 array(),
261 XSPEED_VERSION
262 );
263
264 // menu-icon.css recolors the built-in mark (fill=currentColor) to white
265 // via a brightness/invert filter. A white-label logo is a real image
266 // (often colored), so cancel the filter for it — otherwise the agency
267 // logo renders as a white silhouette. (FBS-82222)
268 $brand = self::branding();
269 if ( ! empty( $brand['logo_svg'] ) ) {
270 wp_add_inline_style(
271 'xspeed-menu-icon',
272 '#toplevel_page_' . self::PAGE_SLUG . ' .wp-menu-image img{filter:none;opacity:1}'
273 );
274 }
275 }
276
277 public function enqueue( $hook ) {
278 if ( 'toplevel_page_' . self::PAGE_SLUG !== $hook ) {
279 return;
280 }
281
282 $asset_js = XSPEED_DIR . 'assets/admin.js';
283 $asset_css = XSPEED_DIR . 'assets/admin.css';
284
285 // Needed for the media-library picker used by schema 'media' fields
286 // (e.g. the white-label brand logo). Loads window.wp.media.
287 wp_enqueue_media();
288
289 if ( file_exists( $asset_js ) ) {
290 // filemtime() cache-busts on every rebuild so a stable
291 // VERSION constant never serves stale JS through browser
292 // caches.
293 wp_enqueue_script(
294 'xspeed-admin',
295 XSPEED_URL . 'assets/admin.js',
296 array( 'wp-api-fetch', 'wp-i18n' ),
297 XSPEED_VERSION . '.' . filemtime( $asset_js ),
298 true
299 );
300 // Loads .mo files for the 'xspeed' text-domain into
301 // window.wp.i18n so the React `__()` helper resolves.
302 if ( function_exists( 'wp_set_script_translations' ) ) {
303 wp_set_script_translations(
304 'xspeed-admin',
305 'xspeed',
306 XSPEED_DIR . 'languages'
307 );
308 }
309 }
310
311 if ( file_exists( $asset_css ) ) {
312 wp_enqueue_style(
313 'xspeed-admin',
314 XSPEED_URL . 'assets/admin.css',
315 array(),
316 XSPEED_VERSION . '.' . filemtime( $asset_css )
317 );
318 }
319
320 /**
321 * Fires after the Free dashboard bundle is enqueued, before its
322 * config is localized. Pro hooks this to enqueue its own bundle
323 * with `xspeed-admin` as a dependency, so its panel
324 * registrations run after `window.XSpeedPro` is installed by
325 * Free's main.tsx.
326 *
327 * @since 1.5.0
328 */
329 do_action( 'xspeed_admin_enqueue', $hook );
330
331 wp_localize_script(
332 'xspeed-admin',
333 'XSpeedConfig',
334 array(
335 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ),
336 'nonce' => wp_create_nonce( 'wp_rest' ),
337 'version' => XSPEED_VERSION,
338 'branding' => self::branding(),
339 // Site's UTC offset in seconds — so datetime pickers (e.g. the
340 // Pro prewarm scheduler) align with WP's own post-scheduling,
341 // which is site-time, not the admin's browser-local time.
342 'gmtOffset' => (int) round( (float) get_option( 'gmt_offset', 0 ) * HOUR_IN_SECONDS ),
343 // 'pro' when xspeed-pro is active + speaks our API
344 // version (see Tier_Registry); 'free' otherwise.
345 // 'trial' reserved for future license-server work.
346 'tier' => class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active() ? 'pro' : 'free',
347 'bootstrap' => self::bootstrap_payload(),
348 )
349 );
350 }
351
352 /**
353 * Pre-rendered settings + status payload, baked into the page so the
354 * React app can mount with real values instead of showing a loading state
355 * while it waits for /settings and /status REST calls.
356 */
357 private static function bootstrap_payload() {
358 $opts = Settings::get();
359 $stats = Cache::get_stats();
360 // Static-rewrite probe state shipped to the React side so the
361 // dashboard can show a persistent banner when nginx/Apache
362 // hasn't been wired to bypass PHP yet. Cache-ONLY here (no $allow_probe
363 // arg) so the dashboard bootstrap never makes the loopback HTTP probe
364 // — that could add seconds to every admin page load on hosts that
365 // stall self-requests. The Health tab runs the live probe on demand;
366 // here we just surface whatever it last cached. (FBS-82142)
367 $server_type = Server::type();
368 // LiteSpeed serves hits via the PHP drop-in by design (its .htaccess
369 // can't add the HIT header or log a static hit), so the static-rewrite
370 // probe is N/A there — surfacing it would pop the "PHP fallback" nag
371 // for a setup working as designed. Only nginx + Apache probe.
372 $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE );
373 $rewrite_probe = null;
374 if ( $opts['cache_enabled'] && $rewrite_capable ) {
375 $probe = Cache::probe_static_rewrite();
376 $rewrite_probe = array(
377 'active' => (bool) ( $probe['active'] ?? false ),
378 'server_type' => $server_type,
379 'snippet' => Cache::nginx_snippet(), // null on non-nginx hosts
380 'topology' => Server::rewrite_topology(),
381 // A reverse proxy / CDN in front (X-Forwarded-* present) usually
382 // means the request-terminating nginx isn't user-editable on this
383 // host — the banner uses this to switch to honest messaging
384 // instead of dangling a snippet the user can't apply.
385 'behind_proxy' => Server::is_behind_proxy(),
386 );
387 }
388
389 return array(
390 'settings' => $opts,
391 'status' => array(
392 'enabled' => (bool) $opts['cache_enabled'],
393 'stats' => $stats,
394 'server' => array(
395 'type' => $server_type,
396 'gzip_mode' => Server::gzip_mode(),
397 'gzip_active' => Gzip::probe_active(),
398 'nginx_snippet' => Gzip::nginx_snippet(),
399 ),
400 'rewrite_probe' => $rewrite_probe,
401 // One consolidated nginx server-block snippet aggregating
402 // every enabled module's directives (Cache static-rewrite,
403 // BrowserCache headers, GZIP, …). Null on non-nginx hosts
404 // or when no module contributes directives. Replaces the
405 // per-module "paste this snippet" notices.
406 'nginx_server_block' => Cache::full_nginx_server_block(),
407 ),
408 // Registered Modules (Free + Pro). The React app discovers them
409 // here and renders one sidebar item + one panel per module that
410 // declares a settings schema. Hidden modules are filtered.
411 'modules' => self::modules_payload(),
412 );
413 }
414
415 /**
416 * Serialize every available Module for the React dashboard. Each entry
417 * carries enough to render: identity (slug + tier), UI metadata (label,
418 * icon, optional description), current settings, and the typed schema
419 * the panel uses to render controls.
420 *
421 * Modules that declare `hidden => true` in ui_metadata (e.g., engine
422 * modules with no user-facing settings) are skipped.
423 */
424 public static function modules_payload() {
425 if ( ! class_exists( 'XSpeed\\Module_Registry' ) ) {
426 return array();
427 }
428 $out = array();
429 foreach ( Module_Registry::available() as $slug => $module ) {
430 $meta = $module->ui_metadata();
431 if ( ! empty( $meta['hidden'] ) ) {
432 continue;
433 }
434 $schema = $module->settings_schema();
435 $custom_panel = $meta['custom_panel'] ?? null;
436 // Skip only when the module has neither a schema nor a custom
437 // panel — i.e., truly nothing to render in the dashboard.
438 if ( empty( $schema ) && empty( $custom_panel ) ) {
439 continue;
440 }
441 $entry = array(
442 'slug' => $slug,
443 'tier' => $module->tier(),
444 'version' => $module->version(),
445 'label' => $meta['label'] ?? ucfirst( $slug ),
446 'icon' => $meta['icon'] ?? 'Square',
447 'description' => $meta['description'] ?? '',
448 'settings' => Settings_Manager::get( $slug ),
449 'schema' => $schema,
450 'notices' => $module->ui_notices(),
451 'custom_panel' => $meta['custom_panel'] ?? null,
452 );
453
454 /**
455 * Last-mile descriptor filter. Lets Pro (or third-party
456 * extensions) override any field before the module is
457 * shipped to React. Primary use: xspeed-pro hooks this to
458 * swap `custom_panel` to LicenseLockedPanel for Pro modules
459 * when the license is invalid, so unlocked modules stay
460 * visible in the sidebar (good upsell UX) but the panel
461 * shows an activation prompt instead of the real surface.
462 */
463 $out[] = apply_filters( 'xspeed_module_descriptor', $entry, $module );
464 }
465 return $out;
466 }
467 }
468