PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / trunk
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN vtrunk
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 1.2.1 1.2.2 1.2.3
xspeed / includes / class-rest-api.php

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

572 lines 18.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * REST API endpoints.
4 *
5 * @package XSpeed
6 */
7
8 namespace XSpeed;
9
10 defined( 'ABSPATH' ) || exit;
11
12 class Rest_Api {
13
14 const NAMESPACE_V1 = 'xspeed/v1';
15
16 public function __construct() {
17 add_action( 'rest_api_init', array( $this, 'register' ) );
18 }
19
20 public function register() {
21 register_rest_route(
22 self::NAMESPACE_V1,
23 '/status',
24 array(
25 'methods' => 'GET',
26 'callback' => array( $this, 'get_status' ),
27 'permission_callback' => array( $this, 'permissions' ),
28 )
29 );
30
31 register_rest_route(
32 self::NAMESPACE_V1,
33 '/settings',
34 array(
35 array(
36 'methods' => 'GET',
37 'callback' => array( $this, 'get_settings' ),
38 'permission_callback' => array( $this, 'permissions' ),
39 ),
40 array(
41 'methods' => 'POST',
42 'callback' => array( $this, 'update_settings' ),
43 'permission_callback' => array( $this, 'permissions' ),
44 ),
45 )
46 );
47
48 // Resolved white-label branding. The dashboard refetches this after
49 // a white-label save so the chrome (sidebar name/logo, footer)
50 // updates live without a reload. (FBS white-label-onboarding)
51 register_rest_route(
52 self::NAMESPACE_V1,
53 '/branding',
54 array(
55 'methods' => 'GET',
56 'callback' => array( $this, 'get_branding' ),
57 'permission_callback' => array( $this, 'permissions' ),
58 )
59 );
60
61 register_rest_route(
62 self::NAMESPACE_V1,
63 '/cache/purge',
64 array(
65 'methods' => 'POST',
66 'callback' => array( $this, 'purge' ),
67 'permission_callback' => array( $this, 'permissions' ),
68 )
69 );
70
71 register_rest_route(
72 self::NAMESPACE_V1,
73 '/cache/toggle',
74 array(
75 'methods' => 'POST',
76 'callback' => array( $this, 'toggle_cache' ),
77 'permission_callback' => array( $this, 'permissions' ),
78 )
79 );
80
81 // Force a fresh static-rewrite probe. The result is otherwise cached
82 // for five minutes with nothing to invalidate it, so a user who just
83 // fixed their nginx config had no way to confirm it. (FBS-84012)
84 register_rest_route(
85 self::NAMESPACE_V1,
86 '/cache/recheck-rewrite',
87 array(
88 'methods' => 'POST',
89 'callback' => array( $this, 'recheck_rewrite' ),
90 'permission_callback' => array( $this, 'permissions' ),
91 )
92 );
93
94 register_rest_route(
95 self::NAMESPACE_V1,
96 '/cache/benchmark',
97 array(
98 'methods' => 'GET',
99 'callback' => array( $this, 'benchmark' ),
100 'permission_callback' => array( $this, 'permissions' ),
101 )
102 );
103
104 register_rest_route(
105 self::NAMESPACE_V1,
106 '/cache/benchmark/history',
107 array(
108 'methods' => 'GET',
109 'callback' => array( $this, 'benchmark_history' ),
110 'permission_callback' => array( $this, 'permissions' ),
111 )
112 );
113
114 // Drill-downs behind the four dashboard stat cards. Each is a plain
115 // GET so the same data reaches the CLI and MCP through
116 // `wp xspeed cache inventory|size|purge-log`.
117 register_rest_route(
118 self::NAMESPACE_V1,
119 '/cache/inventory',
120 array(
121 'methods' => 'GET',
122 'callback' => array( $this, 'cache_inventory' ),
123 'permission_callback' => array( $this, 'permissions' ),
124 'args' => array(
125 'limit' => array(
126 'type' => 'integer',
127 'default' => 50,
128 ),
129 'offset' => array(
130 'type' => 'integer',
131 'default' => 0,
132 ),
133 'fresh' => array(
134 'type' => 'boolean',
135 'default' => false,
136 ),
137 ),
138 )
139 );
140
141 register_rest_route(
142 self::NAMESPACE_V1,
143 '/cache/size',
144 array(
145 'methods' => 'GET',
146 'callback' => array( $this, 'cache_size' ),
147 'permission_callback' => array( $this, 'permissions' ),
148 )
149 );
150
151 register_rest_route(
152 self::NAMESPACE_V1,
153 '/cache/purge-log',
154 array(
155 'methods' => 'GET',
156 'callback' => array( $this, 'cache_purge_log' ),
157 'permission_callback' => array( $this, 'permissions' ),
158 'args' => array(
159 'limit' => array(
160 'type' => 'integer',
161 'default' => 25,
162 ),
163 ),
164 )
165 );
166
167 register_rest_route(
168 self::NAMESPACE_V1,
169 '/stats/hit-daily',
170 array(
171 'methods' => 'GET',
172 'callback' => array( $this, 'hit_daily' ),
173 'permission_callback' => array( $this, 'permissions' ),
174 )
175 );
176
177 register_rest_route(
178 self::NAMESPACE_V1,
179 '/recommendations',
180 array(
181 'methods' => 'GET',
182 'callback' => array( $this, 'recommendations' ),
183 'permission_callback' => array( $this, 'permissions' ),
184 )
185 );
186
187 register_rest_route(
188 self::NAMESPACE_V1,
189 '/recommendations/apply',
190 array(
191 'methods' => 'POST',
192 'callback' => array( $this, 'recommendations_apply' ),
193 'permission_callback' => array( $this, 'permissions' ),
194 )
195 );
196
197 register_rest_route(
198 self::NAMESPACE_V1,
199 '/audit/pro',
200 array(
201 'methods' => 'GET',
202 'callback' => array( $this, 'pro_audit' ),
203 'permission_callback' => array( $this, 'permissions' ),
204 )
205 );
206
207 register_rest_route(
208 self::NAMESPACE_V1,
209 '/activity',
210 array(
211 'methods' => 'GET',
212 'callback' => array( $this, 'activity' ),
213 'permission_callback' => array( $this, 'permissions' ),
214 )
215 );
216
217 register_rest_route(
218 self::NAMESPACE_V1,
219 '/modules',
220 array(
221 'methods' => 'GET',
222 'callback' => array( $this, 'get_modules' ),
223 'permission_callback' => array( $this, 'permissions' ),
224 )
225 );
226
227 // On-demand desktop-vs-mobile HTML equality probe (FBS-83145). POST so
228 // it's never triggered by a prefetch/GET; runs only from the dashboard
229 // "Check now" button behind manage_options.
230 register_rest_route(
231 self::NAMESPACE_V1,
232 '/cache/mobile-probe',
233 array(
234 'methods' => 'POST',
235 'callback' => array( $this, 'mobile_probe' ),
236 'permission_callback' => array( $this, 'permissions' ),
237 )
238 );
239
240 // Dismiss the Separate-Mobile-Cache review prompt (FBS-83145).
241 register_rest_route(
242 self::NAMESPACE_V1,
243 '/cache/mobile-review-dismiss',
244 array(
245 'methods' => 'POST',
246 'callback' => array( $this, 'mobile_review_dismiss' ),
247 'permission_callback' => array( $this, 'permissions' ),
248 )
249 );
250 }
251
252 /**
253 * Run the on-demand mobile-equality probe and return the fresh /status
254 * mobile_separate block so the dashboard can update the callout in place.
255 */
256 public function mobile_probe( $request ) {
257 unset( $request );
258 return rest_ensure_response( Cache::probe_mobile_equality() );
259 }
260
261 /**
262 * Clear the migration review flag so the callout stops nagging.
263 */
264 public function mobile_review_dismiss( $request ) {
265 unset( $request );
266 Cache::clear_mobile_separate_review();
267 return rest_ensure_response( array( 'dismissed' => true ) );
268 }
269
270 /**
271 * The registered-module descriptors — same payload baked into the
272 * admin bootstrap (Admin::modules_payload), re-evaluated live. The
273 * dashboard re-fetches this after a license activate/deactivate so a
274 * Pro module's custom_panel flips between its real surface and
275 * LicenseLockedPanel (decided server-side via the
276 * xspeed_module_descriptor filter) WITHOUT a full page reload.
277 */
278 public function get_modules() {
279 return rest_ensure_response( Admin::modules_payload() );
280 }
281
282 /**
283 * Run the Pro audit — scans current settings + cache stats,
284 * returns a personalized list of Pro features that would help
285 * THIS site. See Pro_Audit::run() for the rule set.
286 */
287 public function pro_audit( $request ) {
288 unset( $request );
289 return rest_ensure_response( array( 'suggestions' => Pro_Audit::run() ) );
290 }
291
292 /**
293 * Recent activity-log entries for the Overview activity strip —
294 * newest-first (settings changes, purges, cache toggles, …).
295 *
296 * @param \WP_REST_Request $request Unused.
297 * @return \WP_REST_Response
298 */
299 public function activity( $request ) {
300 unset( $request );
301 return rest_ensure_response( array( 'activity' => Activity_Log::entries() ) );
302 }
303
304 /**
305 * Cache before/after benchmark — fetches home_url() twice (with +
306 * without the bypass header) and returns side-by-side timings for
307 * the dashboard widget.
308 */
309 public function benchmark( $request ) {
310 unset( $request );
311 return rest_ensure_response( Cache_Benchmark::run() );
312 }
313
314 /**
315 * Stored benchmark runs (oldest→newest) + the settings-change events
316 * the trend chart overlays as annotations.
317 */
318 public function benchmark_history( $request ) {
319 $limit = min( 100, max( 1, (int) ( $request['limit'] ?? 100 ) ) );
320 return rest_ensure_response(
321 array(
322 'runs' => Cache_Benchmark::history( $limit ),
323 'changes' => self::settings_change_events(),
324 )
325 );
326 }
327
328 /**
329 * Daily hit/miss aggregates for the 7/30-day trend, plus change events
330 * for annotation markers.
331 */
332 public function hit_daily( $request ) {
333 $days = min( Hit_Counter::DAILY_MAX_DAYS, max( 1, (int) ( $request['days'] ?? 30 ) ) );
334 return rest_ensure_response(
335 array(
336 'days' => Hit_Counter::daily_series( $days ),
337 'changes' => self::settings_change_events(),
338 )
339 );
340 }
341
342 /** Ranked "next best action" recommendations (issue #48). */
343 public function recommendations( $request ) {
344 unset( $request );
345 return rest_ensure_response( array( 'recommendations' => Recommendations::all() ) );
346 }
347
348 /** One-click apply of a recommendation's settings fix. */
349 public function recommendations_apply( $request ) {
350 $id = sanitize_key( (string) ( $request['id'] ?? '' ) );
351 if ( '' === $id ) {
352 return new \WP_Error( 'xspeed_rec_missing_id', __( 'The id argument is required.', 'xspeed' ), array( 'status' => 400 ) );
353 }
354 $result = Recommendations::apply( $id );
355 return is_wp_error( $result ) ? $result : rest_ensure_response( $result );
356 }
357
358 /**
359 * Recent settings_changed activity entries (the chart annotations).
360 *
361 * @return array<int,array{ts:int,message:string}>
362 */
363 private static function settings_change_events(): array {
364 $out = array();
365 foreach ( Activity_Log::entries() as $entry ) {
366 if ( 'settings_changed' === ( $entry['type'] ?? '' ) ) {
367 $out[] = array(
368 'ts' => (int) $entry['ts'],
369 'message' => (string) $entry['message'],
370 );
371 }
372 }
373 return $out;
374 }
375
376 public function permissions() {
377 return current_user_can( 'manage_options' );
378 }
379
380 public function get_status() {
381 $opts = Settings::get();
382 $stats = Cache::get_stats();
383
384 // rewrite_probe + nginx_server_block mirror the admin bootstrap
385 // payload (Admin::bootstrap_data). The dashboard re-fetches /status
386 // after every module save to refresh the consolidated nginx
387 // server-block snippet without a full page reload — if these were
388 // omitted here, the snippet would only ever update on reload (the
389 // QA bug: "Server config snippet requires full page reload to
390 // reflect toggle changes"). Keep this in sync with Admin.
391 $server_type = Server::type();
392 // LiteSpeed deliberately serves hits via the PHP drop-in (its
393 // .htaccess can't add the HIT header or log a static hit), so the
394 // static-rewrite probe is N/A there — surfacing it would pop the
395 // "PHP fallback" nag for a setup that's working as designed. Only
396 // nginx + Apache use a server-level rewrite worth probing.
397 $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE );
398 $rewrite_probe = null;
399 if ( $opts['cache_enabled'] && $rewrite_capable ) {
400 $probe = Cache::probe_static_rewrite();
401 $rewrite_probe = array(
402 'active' => (bool) ( $probe['active'] ?? false ),
403 // Both flags were previously dropped here, so the dashboard
404 // could not tell "proven inactive" from "no result yet" or
405 // "probe failed" — and rendered the configure-your-server
406 // banner for all three. (FBS-84012)
407 'pending' => (bool) ( $probe['pending'] ?? false ),
408 'inconclusive' => (bool) ( $probe['inconclusive'] ?? false ),
409 'reason' => (string) ( $probe['reason'] ?? '' ),
410 'server_type' => $server_type,
411 'snippet' => Cache::nginx_snippet(),
412 'topology' => Server::rewrite_topology(),
413 'behind_proxy' => Server::is_behind_proxy(),
414 );
415 }
416
417 return rest_ensure_response(
418 array(
419 'enabled' => (bool) $opts['cache_enabled'],
420 'stats' => $stats,
421 'server' => array(
422 'type' => $server_type,
423 'gzip_mode' => Server::gzip_mode(),
424 'gzip_active' => Gzip::probe_active(),
425 'nginx_snippet' => Gzip::nginx_snippet(),
426 ),
427 'rewrite_probe' => $rewrite_probe,
428 'nginx_server_block' => Cache::full_nginx_server_block(),
429 // Separate Mobile Cache visibility (FBS-83145). `blocking` is
430 // true when mobile_separate is what's keeping the device-blind
431 // static fast path from installing on a rewrite-capable server;
432 // `needs_review` is true when a migration turned it on for us and
433 // the user hasn't confirmed they actually need it. The dashboard
434 // renders a callout (+ "Check now" equality probe) from these.
435 'mobile_separate' => array(
436 'enabled' => ! empty( Settings::get()['cache_enabled'] ) ? (bool) ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false,
437 'blocking' => $rewrite_capable && 'mobile_separate' === Cache::static_rewrite_block_reason(),
438 'needs_review' => Cache::mobile_separate_needs_review(),
439 ),
440 )
441 );
442 }
443
444 public function get_settings() {
445 return rest_ensure_response( Settings::get() );
446 }
447
448 public function update_settings( \WP_REST_Request $request ) {
449 $params = $request->get_json_params();
450 if ( ! is_array( $params ) ) {
451 $params = $request->get_params();
452 }
453 // `cache_enabled` is the trigger for drop-in install / wp-config.php
454 // edit and must only flow through the dedicated /cache/toggle
455 // endpoint. Strip it here so generic settings updates can never
456 // implicitly write a drop-in or modify wp-config.php.
457 unset( $params['cache_enabled'] );
458 $updated = Settings::update( $params );
459 return rest_ensure_response( $updated );
460 }
461
462 public function purge() {
463 // purge_type( 'all' ) rather than purge_all() so the dashboard button
464 // behaves identically to the admin-bar "Purge All" — including
465 // clearing third-party render caches (Render_Caches).
466 Cache::purge_type( 'all', __( 'dashboard', 'xspeed' ) );
467 return rest_ensure_response( array( 'stats' => Cache::get_stats() ) );
468 }
469
470 /**
471 * The "Cached Pages" drill-down: which pages are cached and how old they
472 * are. Paginated because a busy site's cache is thousands of entries and
473 * the answer to "is my cache working" doesn't need all of them at once.
474 */
475 public function cache_inventory( \WP_REST_Request $request ) {
476 return rest_ensure_response(
477 Cache_Inventory::entries(
478 (int) $request->get_param( 'limit' ),
479 (int) $request->get_param( 'offset' ),
480 (bool) $request->get_param( 'fresh' )
481 )
482 );
483 }
484
485 /** The "Cache Size" drill-down: where the bytes actually go. */
486 public function cache_size() {
487 return rest_ensure_response( Cache_Inventory::size_breakdown() );
488 }
489
490 /** The "Last Purge" drill-down: what cleared the cache, when, and why. */
491 public function cache_purge_log( \WP_REST_Request $request ) {
492 return rest_ensure_response( Cache_Inventory::purge_log( (int) $request->get_param( 'limit' ) ) );
493 }
494
495 /**
496 * Resolved branding ({name, footer_credit, hide_help_links, logo_svg}).
497 * Runs the `xspeed_branding` filter so Pro's white-label override is
498 * reflected. Consumed by the dashboard's post-save branding refresh.
499 */
500 public function get_branding() {
501 return rest_ensure_response( Admin::branding() );
502 }
503
504 /**
505 * Re-run the static-rewrite probe, bypassing the cached result.
506 *
507 * Returns the same shape the dashboard bootstrap uses, so the caller can
508 * swap it straight into state without a second round trip. (FBS-84012)
509 */
510 public function recheck_rewrite() {
511 $raw = Cache::recheck_static_rewrite();
512 $server_type = Server::detect();
513
514 // Qualify the raw probe against known config refusals. The probe
515 // fetches its own file from the static tree, which succeeds even when
516 // no real page is served that way — so an unqualified `active` told
517 // clients the static path was engaged on sites where it demonstrably
518 // wasn't. `block_reason` is exposed so a client can act on the
519 // specific cause rather than re-deriving it. See
520 // Cache::qualify_rewrite_probe().
521 $probe = Cache::qualify_rewrite_probe( $raw );
522
523 return rest_ensure_response(
524 array(
525 'active' => $probe['active'],
526 'pending' => (bool) ( $raw['pending'] ?? false ),
527 'inconclusive' => $probe['inconclusive'],
528 'reason' => $probe['reason'],
529 'block_reason' => $probe['block_reason'],
530 'server_type' => $server_type,
531 'snippet' => Cache::nginx_snippet(),
532 'topology' => Server::rewrite_topology(),
533 'behind_proxy' => Server::is_behind_proxy(),
534 )
535 );
536 }
537
538 public function toggle_cache( \WP_REST_Request $request ) {
539 $params = $request->get_json_params();
540 $enabled = isset( $params['enabled'] ) ? (bool) $params['enabled'] : false;
541
542 // User-explicit drop-in install / wp-config.php edit happens here.
543 // permission_callback above already enforced current_user_can(
544 // 'manage_options' ); the REST nonce is verified by core via the
545 // X-WP-Nonce header.
546 $state = Cache::toggle( $enabled );
547 $updated = Settings::get();
548
549 // Recompute the unified nginx block AFTER cache_enabled is persisted.
550 // Cache::toggle() computes it inline, but cache_enabled isn't written
551 // until the Settings::update() above — so the block inside $state
552 // reflects the PRE-toggle state (CacheModule::nginx_directives() gates
553 // on cache_enabled). Regenerate here so the dashboard's optimistic
554 // update shows the snippet for the state the user just selected.
555 $state['nginx_server_block'] = Cache::full_nginx_server_block();
556
557 return rest_ensure_response(
558 array(
559 'enabled' => $updated['cache_enabled'],
560 // Surfaced at the top level so the dashboard can explain a
561 // refusal rather than silently snapping the toggle back:
562 // Cache::toggle() writes nothing when another plugin owns the
563 // drop-in or WP_CACHE is in a shape we must not rewrite.
564 'blocked' => ! empty( $state['blocked'] ),
565 'blocked_reason' => $state['blocked_reason'] ?? null,
566 'stats' => Cache::get_stats(),
567 'install_state' => $state,
568 )
569 );
570 }
571 }
572