PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.1
1.4.1 1.4.0 1.3.7 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 All 35 releases
xspeed / includes / class-rest-api.php

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

661 lines 22.0 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 // The server-side mirror of "I pasted the block". Only meaningful on a
95 // host where the probe cannot read the rules back — see
96 // nginx_copied_hash().
97 register_rest_route(
98 self::NAMESPACE_V1,
99 '/cache/nginx-copied-hash',
100 array(
101 'methods' => 'POST',
102 'callback' => array( $this, 'nginx_copied_hash' ),
103 'permission_callback' => array( $this, 'permissions' ),
104 'args' => array(
105 'hash' => array(
106 'type' => 'string',
107 'required' => true,
108 ),
109 ),
110 )
111 );
112
113 register_rest_route(
114 self::NAMESPACE_V1,
115 '/cache/benchmark',
116 array(
117 'methods' => 'GET',
118 'callback' => array( $this, 'benchmark' ),
119 'permission_callback' => array( $this, 'permissions' ),
120 )
121 );
122
123 register_rest_route(
124 self::NAMESPACE_V1,
125 '/cache/benchmark/history',
126 array(
127 'methods' => 'GET',
128 'callback' => array( $this, 'benchmark_history' ),
129 'permission_callback' => array( $this, 'permissions' ),
130 )
131 );
132
133 // Drill-downs behind the four dashboard stat cards. Each is a plain
134 // GET so the same data reaches the CLI and MCP through
135 // `wp xspeed cache inventory|size|purge-log`.
136 register_rest_route(
137 self::NAMESPACE_V1,
138 '/cache/inventory',
139 array(
140 'methods' => 'GET',
141 'callback' => array( $this, 'cache_inventory' ),
142 'permission_callback' => array( $this, 'permissions' ),
143 'args' => array(
144 'limit' => array(
145 'type' => 'integer',
146 'default' => 50,
147 ),
148 'offset' => array(
149 'type' => 'integer',
150 'default' => 0,
151 ),
152 'fresh' => array(
153 'type' => 'boolean',
154 'default' => false,
155 ),
156 ),
157 )
158 );
159
160 register_rest_route(
161 self::NAMESPACE_V1,
162 '/cache/size',
163 array(
164 'methods' => 'GET',
165 'callback' => array( $this, 'cache_size' ),
166 'permission_callback' => array( $this, 'permissions' ),
167 )
168 );
169
170 register_rest_route(
171 self::NAMESPACE_V1,
172 '/cache/purge-log',
173 array(
174 'methods' => 'GET',
175 'callback' => array( $this, 'cache_purge_log' ),
176 'permission_callback' => array( $this, 'permissions' ),
177 'args' => array(
178 'limit' => array(
179 'type' => 'integer',
180 'default' => 25,
181 ),
182 ),
183 )
184 );
185
186 register_rest_route(
187 self::NAMESPACE_V1,
188 '/stats/hit-daily',
189 array(
190 'methods' => 'GET',
191 'callback' => array( $this, 'hit_daily' ),
192 'permission_callback' => array( $this, 'permissions' ),
193 )
194 );
195
196 register_rest_route(
197 self::NAMESPACE_V1,
198 '/recommendations',
199 array(
200 'methods' => 'GET',
201 'callback' => array( $this, 'recommendations' ),
202 'permission_callback' => array( $this, 'permissions' ),
203 'args' => array(
204 // `contributed` adds the entries other plugins hand in
205 // through `xspeed_recommendations`. Only the Overview
206 // card asks for them; see all_with_contributed().
207 'include' => array(
208 'type' => 'string',
209 'enum' => array( '', 'contributed' ),
210 ),
211 ),
212 )
213 );
214
215 register_rest_route(
216 self::NAMESPACE_V1,
217 '/recommendations/apply',
218 array(
219 'methods' => 'POST',
220 'callback' => array( $this, 'recommendations_apply' ),
221 'permission_callback' => array( $this, 'permissions' ),
222 )
223 );
224
225 register_rest_route(
226 self::NAMESPACE_V1,
227 '/audit/pro',
228 array(
229 'methods' => 'GET',
230 'callback' => array( $this, 'pro_audit' ),
231 'permission_callback' => array( $this, 'permissions' ),
232 )
233 );
234
235 register_rest_route(
236 self::NAMESPACE_V1,
237 '/activity',
238 array(
239 'methods' => 'GET',
240 'callback' => array( $this, 'activity' ),
241 'permission_callback' => array( $this, 'permissions' ),
242 )
243 );
244
245 register_rest_route(
246 self::NAMESPACE_V1,
247 '/modules',
248 array(
249 'methods' => 'GET',
250 'callback' => array( $this, 'get_modules' ),
251 'permission_callback' => array( $this, 'permissions' ),
252 )
253 );
254
255 // On-demand desktop-vs-mobile HTML equality probe (FBS-83145). POST so
256 // it's never triggered by a prefetch/GET; runs only from the dashboard
257 // "Check now" button behind manage_options.
258 register_rest_route(
259 self::NAMESPACE_V1,
260 '/cache/mobile-probe',
261 array(
262 'methods' => 'POST',
263 'callback' => array( $this, 'mobile_probe' ),
264 'permission_callback' => array( $this, 'permissions' ),
265 )
266 );
267
268 // Dismiss the Separate-Mobile-Cache review prompt (FBS-83145).
269 register_rest_route(
270 self::NAMESPACE_V1,
271 '/cache/mobile-review-dismiss',
272 array(
273 'methods' => 'POST',
274 'callback' => array( $this, 'mobile_review_dismiss' ),
275 'permission_callback' => array( $this, 'permissions' ),
276 )
277 );
278 }
279
280 /**
281 * Run the on-demand mobile-equality probe and return the fresh /status
282 * mobile_separate block so the dashboard can update the callout in place.
283 */
284 public function mobile_probe( $request ) {
285 unset( $request );
286 return rest_ensure_response( Cache::probe_mobile_equality() );
287 }
288
289 /**
290 * Clear the migration review flag so the callout stops nagging.
291 */
292 public function mobile_review_dismiss( $request ) {
293 unset( $request );
294 Cache::clear_mobile_separate_review();
295 return rest_ensure_response( array( 'dismissed' => true ) );
296 }
297
298 /**
299 * The registered-module descriptors — same payload baked into the
300 * admin bootstrap (Admin::modules_payload), re-evaluated live. The
301 * dashboard re-fetches this after a license activate/deactivate so a
302 * Pro module's custom_panel flips between its real surface and
303 * LicenseLockedPanel (decided server-side via the
304 * xspeed_module_descriptor filter) WITHOUT a full page reload.
305 */
306 public function get_modules() {
307 return rest_ensure_response( Admin::modules_payload() );
308 }
309
310 /**
311 * Run the Pro audit — scans current settings + cache stats,
312 * returns a personalized list of Pro features that would help
313 * THIS site. See Pro_Audit::run() for the rule set.
314 */
315 public function pro_audit( $request ) {
316 unset( $request );
317 return rest_ensure_response( array( 'suggestions' => Pro_Audit::run() ) );
318 }
319
320 /**
321 * Recent activity-log entries for the Overview activity strip —
322 * newest-first (settings changes, purges, cache toggles, …).
323 *
324 * @param \WP_REST_Request $request Unused.
325 * @return \WP_REST_Response
326 */
327 public function activity( $request ) {
328 unset( $request );
329 return rest_ensure_response( array( 'activity' => Activity_Log::entries() ) );
330 }
331
332 /**
333 * Cache before/after benchmark — fetches home_url() twice (with +
334 * without the bypass header) and returns side-by-side timings for
335 * the dashboard widget.
336 */
337 public function benchmark( $request ) {
338 unset( $request );
339 return rest_ensure_response( Cache_Benchmark::run() );
340 }
341
342 /**
343 * Stored benchmark runs (oldest→newest) + the settings-change events
344 * the trend chart overlays as annotations.
345 */
346 public function benchmark_history( $request ) {
347 $limit = min( 100, max( 1, (int) ( $request['limit'] ?? 100 ) ) );
348 return rest_ensure_response(
349 array(
350 'runs' => Cache_Benchmark::history( $limit ),
351 'changes' => self::settings_change_events(),
352 )
353 );
354 }
355
356 /**
357 * Daily hit/miss aggregates for the 7/30-day trend, plus change events
358 * for annotation markers.
359 */
360 public function hit_daily( $request ) {
361 $days = min( Hit_Counter::DAILY_MAX_DAYS, max( 1, (int) ( $request['days'] ?? 30 ) ) );
362 return rest_ensure_response(
363 array(
364 'days' => Hit_Counter::daily_series( $days ),
365 'changes' => self::settings_change_events(),
366 )
367 );
368 }
369
370 /** Ranked "next best action" recommendations (issue #48). */
371 public function recommendations( $request ) {
372 $recs = 'contributed' === (string) $request->get_param( 'include' )
373 ? Recommendations::all_with_contributed()
374 : Recommendations::all();
375 return rest_ensure_response( array( 'recommendations' => $recs ) );
376 }
377
378 /** One-click apply of a recommendation's settings fix. */
379 public function recommendations_apply( $request ) {
380 $id = sanitize_key( (string) ( $request['id'] ?? '' ) );
381 if ( '' === $id ) {
382 return new \WP_Error( 'xspeed_rec_missing_id', __( 'The id argument is required.', 'xspeed' ), array( 'status' => 400 ) );
383 }
384 $result = Recommendations::apply( $id );
385 return is_wp_error( $result ) ? $result : rest_ensure_response( $result );
386 }
387
388 /**
389 * Recent settings_changed activity entries (the chart annotations).
390 *
391 * @return array<int,array{ts:int,message:string}>
392 */
393 private static function settings_change_events(): array {
394 $out = array();
395 foreach ( Activity_Log::entries() as $entry ) {
396 if ( 'settings_changed' === ( $entry['type'] ?? '' ) ) {
397 $out[] = array(
398 'ts' => (int) $entry['ts'],
399 'message' => (string) $entry['message'],
400 );
401 }
402 }
403 return $out;
404 }
405
406 public function permissions() {
407 return current_user_can( 'manage_options' );
408 }
409
410 public function get_status() {
411 $opts = Settings::get();
412 $stats = Cache::get_stats();
413
414 // rewrite_probe + nginx_server_block mirror the admin bootstrap
415 // payload (Admin::bootstrap_data). The dashboard re-fetches /status
416 // after every module save to refresh the consolidated nginx
417 // server-block snippet without a full page reload — if these were
418 // omitted here, the snippet would only ever update on reload (the
419 // QA bug: "Server config snippet requires full page reload to
420 // reflect toggle changes"). Keep this in sync with Admin.
421 $server_type = Server::type();
422 // LiteSpeed deliberately serves hits via the PHP drop-in (its
423 // .htaccess can't add the HIT header or log a static hit), so the
424 // static-rewrite probe is N/A there — surfacing it would pop the
425 // "PHP fallback" nag for a setup that's working as designed. Only
426 // nginx + Apache use a server-level rewrite worth probing.
427 $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE );
428 $rewrite_probe = null;
429 if ( $opts['cache_enabled'] && $rewrite_capable ) {
430 $probe = Cache::probe_static_rewrite();
431 $rewrite_probe = array(
432 'active' => (bool) ( $probe['active'] ?? false ),
433 // Both flags were previously dropped here, so the dashboard
434 // could not tell "proven inactive" from "no result yet" or
435 // "probe failed" — and rendered the configure-your-server
436 // banner for all three. (FBS-84012)
437 'pending' => (bool) ( $probe['pending'] ?? false ),
438 'inconclusive' => (bool) ( $probe['inconclusive'] ?? false ),
439 'reason' => (string) ( $probe['reason'] ?? '' ),
440 'server_type' => $server_type,
441 'snippet' => Cache::nginx_snippet(),
442 'topology' => Server::rewrite_topology(),
443 'behind_proxy' => Server::is_behind_proxy(),
444 // Whether the rules the web server is running are the rules
445 // these settings generate — `current`, `stale`, `absent` or
446 // `unknown`, with the hashes both sides compared. Nothing
447 // else can answer that: the nginx block lives in a server
448 // config WordPress cannot read. See Cache::rules_state().
449 'rules' => Cache::rules_state( $probe ),
450 );
451 }
452
453 return rest_ensure_response(
454 array(
455 'enabled' => (bool) $opts['cache_enabled'],
456 'stats' => $stats,
457 'server' => array(
458 'type' => $server_type,
459 'gzip_mode' => Server::gzip_mode(),
460 'gzip_active' => Gzip::probe_active(),
461 'nginx_snippet' => Gzip::nginx_snippet(),
462 ),
463 'rewrite_probe' => $rewrite_probe,
464 'nginx_server_block' => Cache::full_nginx_server_block(),
465 // What enabling the page cache would do to
466 // wp-content/advanced-cache.php. The dashboard discloses the
467 // replacement BEFORE the write when a leftover drop-in is
468 // already there; see Page_Cache_Detector::dropin_disclosure().
469 'dropin' => Page_Cache_Detector::dropin_disclosure(),
470 // Separate Mobile Cache visibility (FBS-83145). `blocking` is
471 // true when mobile_separate is what's keeping the device-blind
472 // static fast path from installing on a rewrite-capable server;
473 // `needs_review` is true when a migration turned it on for us and
474 // the user hasn't confirmed they actually need it. The dashboard
475 // renders a callout (+ "Check now" equality probe) from these.
476 'mobile_separate' => array(
477 'enabled' => ! empty( Settings::get()['cache_enabled'] ) ? (bool) ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false,
478 // Gated to servers that HAVE a static fast path — see the
479 // matching comment in Admin::bootstrap_payload(): on IIS /
480 // unknown, block_reason still falls through to
481 // mobile_separate and reporting it as "blocking" would nag
482 // about a rewrite that does not exist there (#108).
483 // LiteSpeed joined the capable set with the opt-in (#509).
484 'blocking' => ( $rewrite_capable || Server::LITESPEED === $server_type )
485 && 'mobile_separate' === Cache::static_rewrite_block_reason(),
486 'needs_review' => Cache::mobile_separate_needs_review(),
487 ),
488 )
489 );
490 }
491
492 public function get_settings() {
493 return rest_ensure_response( Settings::get() );
494 }
495
496 public function update_settings( \WP_REST_Request $request ) {
497 $params = $request->get_json_params();
498 if ( ! is_array( $params ) ) {
499 $params = $request->get_params();
500 }
501 // `cache_enabled` is the trigger for drop-in install / wp-config.php
502 // edit and must only flow through the dedicated /cache/toggle
503 // endpoint. Strip it here so generic settings updates can never
504 // implicitly write a drop-in or modify wp-config.php.
505 unset( $params['cache_enabled'] );
506 $updated = Settings::update( $params );
507 return rest_ensure_response( $updated );
508 }
509
510 public function purge() {
511 // The same core function `wp xspeed purge` runs, so the dashboard
512 // button and the CLI cannot clear different sets of stores — and the
513 // per-store report is available here for the UI to surface a store
514 // that was skipped or refused rather than flashing "cache cleared".
515 $report = Purge_Runner::run( array( 'all' ), __( 'dashboard', 'xspeed' ) );
516 return rest_ensure_response(
517 array(
518 'stats' => Cache::get_stats(),
519 'report' => $report,
520 )
521 );
522 }
523
524 /**
525 * The "Cached Pages" drill-down: which pages are cached and how old they
526 * are. Paginated because a busy site's cache is thousands of entries and
527 * the answer to "is my cache working" doesn't need all of them at once.
528 */
529 public function cache_inventory( \WP_REST_Request $request ) {
530 return rest_ensure_response(
531 Cache_Inventory::entries(
532 (int) $request->get_param( 'limit' ),
533 (int) $request->get_param( 'offset' ),
534 (bool) $request->get_param( 'fresh' )
535 )
536 );
537 }
538
539 /** The "Cache Size" drill-down: where the bytes actually go. */
540 public function cache_size() {
541 return rest_ensure_response( Cache_Inventory::size_breakdown() );
542 }
543
544 /** The "Last Purge" drill-down: what cleared the cache, when, and why. */
545 public function cache_purge_log( \WP_REST_Request $request ) {
546 return rest_ensure_response( Cache_Inventory::purge_log( (int) $request->get_param( 'limit' ) ) );
547 }
548
549 /**
550 * Resolved branding ({name, footer_credit, hide_help_links, logo_svg}).
551 * Runs the `xspeed_branding` filter so Pro's white-label override is
552 * reflected. Consumed by the dashboard's post-save branding refresh.
553 */
554 public function get_branding() {
555 return rest_ensure_response( Admin::branding() );
556 }
557
558 /**
559 * Re-run the static-rewrite probe, bypassing the cached result.
560 *
561 * Returns the same shape the dashboard bootstrap uses, so the caller can
562 * swap it straight into state without a second round trip. (FBS-84012)
563 */
564 public function recheck_rewrite() {
565 $raw = Cache::recheck_static_rewrite();
566 $server_type = Server::detect();
567
568 // Qualify the raw probe against known config refusals. The probe
569 // fetches its own file from the static tree, which succeeds even when
570 // no real page is served that way — so an unqualified `active` told
571 // clients the static path was engaged on sites where it demonstrably
572 // wasn't. `block_reason` is exposed so a client can act on the
573 // specific cause rather than re-deriving it. See
574 // Cache::qualify_rewrite_probe().
575 $probe = Cache::qualify_rewrite_probe( $raw );
576
577 return rest_ensure_response(
578 array(
579 'active' => $probe['active'],
580 'pending' => (bool) ( $raw['pending'] ?? false ),
581 'inconclusive' => $probe['inconclusive'],
582 'reason' => $probe['reason'],
583 'block_reason' => $probe['block_reason'],
584 'server_type' => $server_type,
585 'snippet' => Cache::nginx_snippet(),
586 'topology' => Server::rewrite_topology(),
587 'behind_proxy' => Server::is_behind_proxy(),
588 // The whole reason to re-run the probe is usually that the
589 // user just pasted the block, so this is where they most need
590 // to be told whether the installed rules are the current ones.
591 // It was only ever on /status before, which the CLI and MCP
592 // recheck paths never call. See Cache::rules_state().
593 'rules' => $probe['rules'],
594 )
595 );
596 }
597
598 /**
599 * Remember that this admin copied the current rules block.
600 *
601 * The mirror of the panel's own localStorage note, for the one case that
602 * note cannot cover: a host where the probe returns `unknown` — blocked
603 * loopback, or a CDN answering it — and a second admin, or the same admin
604 * on another machine, is otherwise told to paste a block that is already
605 * installed. Stored per user because it is a claim a person made.
606 *
607 * The hash is the rules marker, and a value that is not one is refused
608 * rather than stored: the mirror is only useful while it holds something
609 * rules_marker_expected() could also produce.
610 */
611 public function nginx_copied_hash( \WP_REST_Request $request ) {
612 $params = (array) $request->get_json_params();
613 $hash = isset( $params['hash'] ) ? (string) $params['hash'] : (string) $request->get_param( 'hash' );
614
615 $stored = Cache::remember_rules_copied( $hash );
616 if ( null === $stored ) {
617 return new \WP_Error(
618 'xspeed_invalid_rules_hash',
619 __( 'That is not a rules marker this site could have generated.', 'xspeed' ),
620 array( 'status' => 400 )
621 );
622 }
623
624 return rest_ensure_response( array( 'copied' => $stored ) );
625 }
626
627 public function toggle_cache( \WP_REST_Request $request ) {
628 $params = $request->get_json_params();
629 $enabled = isset( $params['enabled'] ) ? (bool) $params['enabled'] : false;
630
631 // User-explicit drop-in install / wp-config.php edit happens here.
632 // permission_callback above already enforced current_user_can(
633 // 'manage_options' ); the REST nonce is verified by core via the
634 // X-WP-Nonce header.
635 $state = Cache::toggle( $enabled );
636 $updated = Settings::get();
637
638 // Recompute the unified nginx block AFTER cache_enabled is persisted.
639 // Cache::toggle() computes it inline, but cache_enabled isn't written
640 // until the Settings::update() above — so the block inside $state
641 // reflects the PRE-toggle state (CacheModule::nginx_directives() gates
642 // on cache_enabled). Regenerate here so the dashboard's optimistic
643 // update shows the snippet for the state the user just selected.
644 $state['nginx_server_block'] = Cache::full_nginx_server_block();
645
646 return rest_ensure_response(
647 array(
648 'enabled' => $updated['cache_enabled'],
649 // Surfaced at the top level so the dashboard can explain a
650 // refusal rather than silently snapping the toggle back:
651 // Cache::toggle() writes nothing when another plugin owns the
652 // drop-in or WP_CACHE is in a shape we must not rewrite.
653 'blocked' => ! empty( $state['blocked'] ),
654 'blocked_reason' => $state['blocked_reason'] ?? null,
655 'stats' => Cache::get_stats(),
656 'install_state' => $state,
657 )
658 );
659 }
660 }
661