PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.2.1 All 27 releases
xspeed / includes / class-rest-api.php

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

583 lines 18.7 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 // What enabling the page cache would do to
430 // wp-content/advanced-cache.php. The dashboard discloses the
431 // replacement BEFORE the write when a leftover drop-in is
432 // already there; see Page_Cache_Detector::dropin_disclosure().
433 'dropin' => Page_Cache_Detector::dropin_disclosure(),
434 // Separate Mobile Cache visibility (FBS-83145). `blocking` is
435 // true when mobile_separate is what's keeping the device-blind
436 // static fast path from installing on a rewrite-capable server;
437 // `needs_review` is true when a migration turned it on for us and
438 // the user hasn't confirmed they actually need it. The dashboard
439 // renders a callout (+ "Check now" equality probe) from these.
440 'mobile_separate' => array(
441 'enabled' => ! empty( Settings::get()['cache_enabled'] ) ? (bool) ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false,
442 'blocking' => $rewrite_capable && 'mobile_separate' === Cache::static_rewrite_block_reason(),
443 'needs_review' => Cache::mobile_separate_needs_review(),
444 ),
445 )
446 );
447 }
448
449 public function get_settings() {
450 return rest_ensure_response( Settings::get() );
451 }
452
453 public function update_settings( \WP_REST_Request $request ) {
454 $params = $request->get_json_params();
455 if ( ! is_array( $params ) ) {
456 $params = $request->get_params();
457 }
458 // `cache_enabled` is the trigger for drop-in install / wp-config.php
459 // edit and must only flow through the dedicated /cache/toggle
460 // endpoint. Strip it here so generic settings updates can never
461 // implicitly write a drop-in or modify wp-config.php.
462 unset( $params['cache_enabled'] );
463 $updated = Settings::update( $params );
464 return rest_ensure_response( $updated );
465 }
466
467 public function purge() {
468 // The same core function `wp xspeed purge` runs, so the dashboard
469 // button and the CLI cannot clear different sets of stores — and the
470 // per-store report is available here for the UI to surface a store
471 // that was skipped or refused rather than flashing "cache cleared".
472 $report = Purge_Runner::run( array( 'all' ), __( 'dashboard', 'xspeed' ) );
473 return rest_ensure_response(
474 array(
475 'stats' => Cache::get_stats(),
476 'report' => $report,
477 )
478 );
479 }
480
481 /**
482 * The "Cached Pages" drill-down: which pages are cached and how old they
483 * are. Paginated because a busy site's cache is thousands of entries and
484 * the answer to "is my cache working" doesn't need all of them at once.
485 */
486 public function cache_inventory( \WP_REST_Request $request ) {
487 return rest_ensure_response(
488 Cache_Inventory::entries(
489 (int) $request->get_param( 'limit' ),
490 (int) $request->get_param( 'offset' ),
491 (bool) $request->get_param( 'fresh' )
492 )
493 );
494 }
495
496 /** The "Cache Size" drill-down: where the bytes actually go. */
497 public function cache_size() {
498 return rest_ensure_response( Cache_Inventory::size_breakdown() );
499 }
500
501 /** The "Last Purge" drill-down: what cleared the cache, when, and why. */
502 public function cache_purge_log( \WP_REST_Request $request ) {
503 return rest_ensure_response( Cache_Inventory::purge_log( (int) $request->get_param( 'limit' ) ) );
504 }
505
506 /**
507 * Resolved branding ({name, footer_credit, hide_help_links, logo_svg}).
508 * Runs the `xspeed_branding` filter so Pro's white-label override is
509 * reflected. Consumed by the dashboard's post-save branding refresh.
510 */
511 public function get_branding() {
512 return rest_ensure_response( Admin::branding() );
513 }
514
515 /**
516 * Re-run the static-rewrite probe, bypassing the cached result.
517 *
518 * Returns the same shape the dashboard bootstrap uses, so the caller can
519 * swap it straight into state without a second round trip. (FBS-84012)
520 */
521 public function recheck_rewrite() {
522 $raw = Cache::recheck_static_rewrite();
523 $server_type = Server::detect();
524
525 // Qualify the raw probe against known config refusals. The probe
526 // fetches its own file from the static tree, which succeeds even when
527 // no real page is served that way — so an unqualified `active` told
528 // clients the static path was engaged on sites where it demonstrably
529 // wasn't. `block_reason` is exposed so a client can act on the
530 // specific cause rather than re-deriving it. See
531 // Cache::qualify_rewrite_probe().
532 $probe = Cache::qualify_rewrite_probe( $raw );
533
534 return rest_ensure_response(
535 array(
536 'active' => $probe['active'],
537 'pending' => (bool) ( $raw['pending'] ?? false ),
538 'inconclusive' => $probe['inconclusive'],
539 'reason' => $probe['reason'],
540 'block_reason' => $probe['block_reason'],
541 'server_type' => $server_type,
542 'snippet' => Cache::nginx_snippet(),
543 'topology' => Server::rewrite_topology(),
544 'behind_proxy' => Server::is_behind_proxy(),
545 )
546 );
547 }
548
549 public function toggle_cache( \WP_REST_Request $request ) {
550 $params = $request->get_json_params();
551 $enabled = isset( $params['enabled'] ) ? (bool) $params['enabled'] : false;
552
553 // User-explicit drop-in install / wp-config.php edit happens here.
554 // permission_callback above already enforced current_user_can(
555 // 'manage_options' ); the REST nonce is verified by core via the
556 // X-WP-Nonce header.
557 $state = Cache::toggle( $enabled );
558 $updated = Settings::get();
559
560 // Recompute the unified nginx block AFTER cache_enabled is persisted.
561 // Cache::toggle() computes it inline, but cache_enabled isn't written
562 // until the Settings::update() above — so the block inside $state
563 // reflects the PRE-toggle state (CacheModule::nginx_directives() gates
564 // on cache_enabled). Regenerate here so the dashboard's optimistic
565 // update shows the snippet for the state the user just selected.
566 $state['nginx_server_block'] = Cache::full_nginx_server_block();
567
568 return rest_ensure_response(
569 array(
570 'enabled' => $updated['cache_enabled'],
571 // Surfaced at the top level so the dashboard can explain a
572 // refusal rather than silently snapping the toggle back:
573 // Cache::toggle() writes nothing when another plugin owns the
574 // drop-in or WP_CACHE is in a shape we must not rewrite.
575 'blocked' => ! empty( $state['blocked'] ),
576 'blocked_reason' => $state['blocked_reason'] ?? null,
577 'stats' => Cache::get_stats(),
578 'install_state' => $state,
579 )
580 );
581 }
582 }
583