PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.2.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.2.0
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-rest-api.php

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

566 lines 18.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 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::update( array( 'cache_enabled' => $state['enabled'] ) );
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 'stats' => Cache::get_stats(),
561 'install_state' => $state,
562 )
563 );
564 }
565 }
566