PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.1
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 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / class-rest-api.php

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

409 lines 12.8 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 register_rest_route(
82 self::NAMESPACE_V1,
83 '/cache/benchmark',
84 array(
85 'methods' => 'GET',
86 'callback' => array( $this, 'benchmark' ),
87 'permission_callback' => array( $this, 'permissions' ),
88 )
89 );
90
91 register_rest_route(
92 self::NAMESPACE_V1,
93 '/cache/benchmark/history',
94 array(
95 'methods' => 'GET',
96 'callback' => array( $this, 'benchmark_history' ),
97 'permission_callback' => array( $this, 'permissions' ),
98 )
99 );
100
101 register_rest_route(
102 self::NAMESPACE_V1,
103 '/stats/hit-daily',
104 array(
105 'methods' => 'GET',
106 'callback' => array( $this, 'hit_daily' ),
107 'permission_callback' => array( $this, 'permissions' ),
108 )
109 );
110
111 register_rest_route(
112 self::NAMESPACE_V1,
113 '/recommendations',
114 array(
115 'methods' => 'GET',
116 'callback' => array( $this, 'recommendations' ),
117 'permission_callback' => array( $this, 'permissions' ),
118 )
119 );
120
121 register_rest_route(
122 self::NAMESPACE_V1,
123 '/recommendations/apply',
124 array(
125 'methods' => 'POST',
126 'callback' => array( $this, 'recommendations_apply' ),
127 'permission_callback' => array( $this, 'permissions' ),
128 )
129 );
130
131 register_rest_route(
132 self::NAMESPACE_V1,
133 '/audit/pro',
134 array(
135 'methods' => 'GET',
136 'callback' => array( $this, 'pro_audit' ),
137 'permission_callback' => array( $this, 'permissions' ),
138 )
139 );
140
141 register_rest_route(
142 self::NAMESPACE_V1,
143 '/modules',
144 array(
145 'methods' => 'GET',
146 'callback' => array( $this, 'get_modules' ),
147 'permission_callback' => array( $this, 'permissions' ),
148 )
149 );
150
151 // On-demand desktop-vs-mobile HTML equality probe (FBS-83145). POST so
152 // it's never triggered by a prefetch/GET; runs only from the dashboard
153 // "Check now" button behind manage_options.
154 register_rest_route(
155 self::NAMESPACE_V1,
156 '/cache/mobile-probe',
157 array(
158 'methods' => 'POST',
159 'callback' => array( $this, 'mobile_probe' ),
160 'permission_callback' => array( $this, 'permissions' ),
161 )
162 );
163
164 // Dismiss the Separate-Mobile-Cache review prompt (FBS-83145).
165 register_rest_route(
166 self::NAMESPACE_V1,
167 '/cache/mobile-review-dismiss',
168 array(
169 'methods' => 'POST',
170 'callback' => array( $this, 'mobile_review_dismiss' ),
171 'permission_callback' => array( $this, 'permissions' ),
172 )
173 );
174 }
175
176 /**
177 * Run the on-demand mobile-equality probe and return the fresh /status
178 * mobile_separate block so the dashboard can update the callout in place.
179 */
180 public function mobile_probe( $request ) {
181 unset( $request );
182 return rest_ensure_response( Cache::probe_mobile_equality() );
183 }
184
185 /**
186 * Clear the migration review flag so the callout stops nagging.
187 */
188 public function mobile_review_dismiss( $request ) {
189 unset( $request );
190 Cache::clear_mobile_separate_review();
191 return rest_ensure_response( array( 'dismissed' => true ) );
192 }
193
194 /**
195 * The registered-module descriptors — same payload baked into the
196 * admin bootstrap (Admin::modules_payload), re-evaluated live. The
197 * dashboard re-fetches this after a license activate/deactivate so a
198 * Pro module's custom_panel flips between its real surface and
199 * LicenseLockedPanel (decided server-side via the
200 * xspeed_module_descriptor filter) WITHOUT a full page reload.
201 */
202 public function get_modules() {
203 return rest_ensure_response( Admin::modules_payload() );
204 }
205
206 /**
207 * Run the Pro audit — scans current settings + cache stats,
208 * returns a personalized list of Pro features that would help
209 * THIS site. See Pro_Audit::run() for the rule set.
210 */
211 public function pro_audit( $request ) {
212 unset( $request );
213 return rest_ensure_response( array( 'suggestions' => Pro_Audit::run() ) );
214 }
215
216 /**
217 * Cache before/after benchmark — fetches home_url() twice (with +
218 * without the bypass header) and returns side-by-side timings for
219 * the dashboard widget.
220 */
221 public function benchmark( $request ) {
222 unset( $request );
223 return rest_ensure_response( Cache_Benchmark::run() );
224 }
225
226 /**
227 * Stored benchmark runs (oldest→newest) + the settings-change events
228 * the trend chart overlays as annotations.
229 */
230 public function benchmark_history( $request ) {
231 $limit = min( 100, max( 1, (int) ( $request['limit'] ?? 100 ) ) );
232 return rest_ensure_response(
233 array(
234 'runs' => Cache_Benchmark::history( $limit ),
235 'changes' => self::settings_change_events(),
236 )
237 );
238 }
239
240 /**
241 * Daily hit/miss aggregates for the 7/30-day trend, plus change events
242 * for annotation markers.
243 */
244 public function hit_daily( $request ) {
245 $days = min( Hit_Counter::DAILY_MAX_DAYS, max( 1, (int) ( $request['days'] ?? 30 ) ) );
246 return rest_ensure_response(
247 array(
248 'days' => Hit_Counter::daily_series( $days ),
249 'changes' => self::settings_change_events(),
250 )
251 );
252 }
253
254 /** Ranked "next best action" recommendations (issue #48). */
255 public function recommendations( $request ) {
256 unset( $request );
257 return rest_ensure_response( array( 'recommendations' => Recommendations::all() ) );
258 }
259
260 /** One-click apply of a recommendation's settings fix. */
261 public function recommendations_apply( $request ) {
262 $id = sanitize_key( (string) ( $request['id'] ?? '' ) );
263 if ( '' === $id ) {
264 return new \WP_Error( 'xspeed_rec_missing_id', __( 'The id argument is required.', 'xspeed' ), array( 'status' => 400 ) );
265 }
266 $result = Recommendations::apply( $id );
267 return is_wp_error( $result ) ? $result : rest_ensure_response( $result );
268 }
269
270 /**
271 * Recent settings_changed activity entries (the chart annotations).
272 *
273 * @return array<int,array{ts:int,message:string}>
274 */
275 private static function settings_change_events(): array {
276 $out = array();
277 foreach ( Activity_Log::entries() as $entry ) {
278 if ( 'settings_changed' === ( $entry['type'] ?? '' ) ) {
279 $out[] = array(
280 'ts' => (int) $entry['ts'],
281 'message' => (string) $entry['message'],
282 );
283 }
284 }
285 return $out;
286 }
287
288 public function permissions() {
289 return current_user_can( 'manage_options' );
290 }
291
292 public function get_status() {
293 $opts = Settings::get();
294 $stats = Cache::get_stats();
295
296 // rewrite_probe + nginx_server_block mirror the admin bootstrap
297 // payload (Admin::bootstrap_data). The dashboard re-fetches /status
298 // after every module save to refresh the consolidated nginx
299 // server-block snippet without a full page reload — if these were
300 // omitted here, the snippet would only ever update on reload (the
301 // QA bug: "Server config snippet requires full page reload to
302 // reflect toggle changes"). Keep this in sync with Admin.
303 $server_type = Server::type();
304 // LiteSpeed deliberately serves hits via the PHP drop-in (its
305 // .htaccess can't add the HIT header or log a static hit), so the
306 // static-rewrite probe is N/A there — surfacing it would pop the
307 // "PHP fallback" nag for a setup that's working as designed. Only
308 // nginx + Apache use a server-level rewrite worth probing.
309 $rewrite_capable = ( $server_type === Server::NGINX || $server_type === Server::APACHE );
310 $rewrite_probe = null;
311 if ( $opts['cache_enabled'] && $rewrite_capable ) {
312 $probe = Cache::probe_static_rewrite();
313 $rewrite_probe = array(
314 'active' => (bool) ( $probe['active'] ?? false ),
315 'server_type' => $server_type,
316 'snippet' => Cache::nginx_snippet(),
317 'topology' => Server::rewrite_topology(),
318 'behind_proxy' => Server::is_behind_proxy(),
319 );
320 }
321
322 return rest_ensure_response(
323 array(
324 'enabled' => (bool) $opts['cache_enabled'],
325 'stats' => $stats,
326 'server' => array(
327 'type' => $server_type,
328 'gzip_mode' => Server::gzip_mode(),
329 'gzip_active' => Gzip::probe_active(),
330 'nginx_snippet' => Gzip::nginx_snippet(),
331 ),
332 'rewrite_probe' => $rewrite_probe,
333 'nginx_server_block' => Cache::full_nginx_server_block(),
334 // Separate Mobile Cache visibility (FBS-83145). `blocking` is
335 // true when mobile_separate is what's keeping the device-blind
336 // static fast path from installing on a rewrite-capable server;
337 // `needs_review` is true when a migration turned it on for us and
338 // the user hasn't confirmed they actually need it. The dashboard
339 // renders a callout (+ "Check now" equality probe) from these.
340 'mobile_separate' => array(
341 'enabled' => ! empty( Settings::get()['cache_enabled'] ) ? (bool) ( Settings_Manager::get( 'cache' )['mobile_separate'] ?? false ) : false,
342 'blocking' => $rewrite_capable && 'mobile_separate' === Cache::static_rewrite_block_reason(),
343 'needs_review' => Cache::mobile_separate_needs_review(),
344 ),
345 )
346 );
347 }
348
349 public function get_settings() {
350 return rest_ensure_response( Settings::get() );
351 }
352
353 public function update_settings( \WP_REST_Request $request ) {
354 $params = $request->get_json_params();
355 if ( ! is_array( $params ) ) {
356 $params = $request->get_params();
357 }
358 // `cache_enabled` is the trigger for drop-in install / wp-config.php
359 // edit and must only flow through the dedicated /cache/toggle
360 // endpoint. Strip it here so generic settings updates can never
361 // implicitly write a drop-in or modify wp-config.php.
362 unset( $params['cache_enabled'] );
363 $updated = Settings::update( $params );
364 return rest_ensure_response( $updated );
365 }
366
367 public function purge() {
368 Cache::purge_all();
369 return rest_ensure_response( array( 'stats' => Cache::get_stats() ) );
370 }
371
372 /**
373 * Resolved branding ({name, footer_credit, hide_help_links, logo_svg}).
374 * Runs the `xspeed_branding` filter so Pro's white-label override is
375 * reflected. Consumed by the dashboard's post-save branding refresh.
376 */
377 public function get_branding() {
378 return rest_ensure_response( Admin::branding() );
379 }
380
381 public function toggle_cache( \WP_REST_Request $request ) {
382 $params = $request->get_json_params();
383 $enabled = isset( $params['enabled'] ) ? (bool) $params['enabled'] : false;
384
385 // User-explicit drop-in install / wp-config.php edit happens here.
386 // permission_callback above already enforced current_user_can(
387 // 'manage_options' ); the REST nonce is verified by core via the
388 // X-WP-Nonce header.
389 $state = Cache::toggle( $enabled );
390 $updated = Settings::update( array( 'cache_enabled' => $state['enabled'] ) );
391
392 // Recompute the unified nginx block AFTER cache_enabled is persisted.
393 // Cache::toggle() computes it inline, but cache_enabled isn't written
394 // until the Settings::update() above — so the block inside $state
395 // reflects the PRE-toggle state (CacheModule::nginx_directives() gates
396 // on cache_enabled). Regenerate here so the dashboard's optimistic
397 // update shows the snippet for the state the user just selected.
398 $state['nginx_server_block'] = Cache::full_nginx_server_block();
399
400 return rest_ensure_response(
401 array(
402 'enabled' => $updated['cache_enabled'],
403 'stats' => Cache::get_stats(),
404 'install_state' => $state,
405 )
406 );
407 }
408 }
409