PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.1.6
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.1.6
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-manager.php

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

199 lines 7.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Rest_Manager — registers a module's REST routes under
4 * `/xspeed/v1/<module-slug>/...` and wraps every callback with:
5 * - A final capability check (defaults to `manage_options`).
6 * - A tier gate that returns 404 if the module is unavailable on this
7 * install (Pro module without active Pro plugin → 404, not 403, so
8 * the route looks like it doesn't exist).
9 * - A conflict gate (refuse strategy → 409 with conflict details).
10 *
11 * Modules declare routes via Module::rest_routes(). They don't repeat the
12 * cap check / tier gate / conflict gate — Rest_Manager always enforces.
13 *
14 * The pre-existing /xspeed/v1/status, /settings, /cache/purge,
15 * /cache/toggle, /onboarding/* routes (registered by Rest_Api and
16 * Onboarding) continue to work alongside per-module routes. They'll move
17 * onto the module pattern when those features are refactored.
18 *
19 * @package XSpeed
20 */
21
22 namespace XSpeed;
23
24 defined( 'ABSPATH' ) || exit;
25
26 final class Rest_Manager {
27
28 public const NAMESPACE_V1 = 'xspeed/v1';
29
30 /**
31 * Register every route a module declared, prefixed with its slug.
32 */
33 public static function register_module( Module $module ): void {
34 // rest_routes() is resolved INSIDE the callback, not here.
35 //
36 // This method runs at plugins_loaded, before `init`. Calling
37 // rest_routes() there makes a module build its settings schema, and
38 // those schemas carry __() labels — so WordPress emitted a
39 // _load_textdomain_just_in_time notice for every module, on every
40 // request including the front end (135 per page load with WP_DEBUG on).
41 // The eager call existed only to skip add_action() for modules with no
42 // routes; deferring costs one no-op hook each and moves all translation
43 // work to where it belongs. (QA, 9 Aug 2026)
44 add_action(
45 'rest_api_init',
46 static function () use ( $module ) {
47 $routes = $module->rest_routes();
48 if ( empty( $routes ) ) {
49 return;
50 }
51 $slug = $module->slug();
52 foreach ( $routes as $route ) {
53 $path = '/' . trim( $slug, '/' ) . '/' . ltrim( $route['path'] ?? '', '/' );
54 $path = rtrim( $path, '/' );
55
56 $args = array(
57 'methods' => $route['methods'] ?? 'GET',
58 'callback' => self::wrap_callback( $module, $route ),
59 'permission_callback' => self::wrap_permission( $module, $route ),
60 );
61 if ( isset( $route['args'] ) ) {
62 $args['args'] = $route['args'];
63 }
64
65 register_rest_route( self::NAMESPACE_V1, $path, $args );
66 }
67 }
68 );
69 }
70
71 /**
72 * Wrap a module's callback with the tier + conflict gates.
73 */
74 private static function wrap_callback( Module $module, array $route ): callable {
75 $callback = $route['callback'] ?? null;
76 $feature = $route['feature'] ?? null; // optional sub-feature key for conflict resolution.
77
78 return static function ( \WP_REST_Request $request ) use ( $module, $callback, $feature ) {
79 // Tier gate — Pro route without active Pro plugin looks like it doesn't exist.
80 if ( ! Tier_Registry::is_available( $module ) ) {
81 return new \WP_Error(
82 'rest_no_route',
83 __( 'No route was found matching the URL and request method.', 'xspeed' ),
84 array( 'status' => 404 )
85 );
86 }
87
88 // Conflict gate — refuse-strategy → 409.
89 if ( $feature ) {
90 $reason = Conflict_Registry::why_blocked( $module->slug(), $feature );
91 if ( $reason ) {
92 return new \WP_Error(
93 'xspeed_conflict_refused',
94 $reason,
95 array( 'status' => 409 )
96 );
97 }
98 }
99
100 if ( ! is_callable( $callback ) ) {
101 return new \WP_Error( 'xspeed_no_callback', 'Module REST callback is not callable.', array( 'status' => 500 ) );
102 }
103
104 return self::run_isolated( $callback, $request, $module->slug() );
105 };
106 }
107
108 /**
109 * Backstop for every module REST callback. Containment, not a substitute
110 * for per-query guards: callbacks should still avoid failing queries.
111 *
112 * Two things leak non-JSON into a REST body and break the client's
113 * JSON.parse ("Unexpected token '<'"):
114 * 1. $wpdb echoes "WordPress database error: …" as an HTML <div> the
115 * instant a query fails, when display-errors is on (common on the
116 * hosts we ship to). That HTML is flushed BEFORE our handler
117 * returns, so a try/catch around the return value can't catch it.
118 * We suppress $wpdb's echo for the duration of the call (errors are
119 * still logged) and capture any other stray output via an output
120 * buffer, discarding it so only our JSON reaches the client.
121 * 2. A thrown Throwable would surface as a fatal/HTML error page. We
122 * convert it to a clean 500 WP_Error.
123 *
124 * $wpdb's prior show-errors state and the buffer are always restored in
125 * finally, so global state is untouched after the call.
126 */
127 private static function run_isolated( callable $callback, \WP_REST_Request $request, string $slug ) {
128 global $wpdb;
129
130 $prev_show_errors = null;
131 if ( $wpdb instanceof \wpdb ) {
132 // hide_errors() returns the previous flag so we can restore it.
133 $prev_show_errors = $wpdb->hide_errors();
134 }
135
136 ob_start();
137 try {
138 $result = call_user_func( $callback, $request );
139 } catch ( \Throwable $e ) {
140 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
141 error_log( sprintf( '[xspeed] REST callback for "%s" threw: %s', $slug, $e->getMessage() ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Gated behind WP_DEBUG.
142 }
143 $result = new \WP_Error(
144 'xspeed_rest_exception',
145 __( 'The request could not be completed due to a server error.', 'xspeed' ),
146 array( 'status' => 500 )
147 );
148 } finally {
149 // Discard anything the callback (or $wpdb) echoed — DB-error
150 // HTML, notices, debug output — so the response body is JSON only.
151 $stray = ob_get_clean();
152 if ( '' !== $stray && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
153 error_log( sprintf( '[xspeed] discarded %d bytes of stray REST output from "%s"', strlen( $stray ), $slug ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Gated behind WP_DEBUG.
154 }
155 if ( $wpdb instanceof \wpdb && false !== $prev_show_errors && null !== $prev_show_errors ) {
156 $wpdb->show_errors();
157 }
158 }
159
160 return $result;
161 }
162
163 /**
164 * Wrap permission_callback with the always-on cap check. A module may
165 * declare its own permission_callback for an extra-strict gate; both
166 * must pass.
167 *
168 * A route may opt out of the capability check with
169 * `'allow_unauthenticated' => true`. That is ONLY for endpoints designed to
170 * be called by anonymous frontend visitors — the RUM beacon is the reason
171 * this exists: it collects Core Web Vitals from real visitors, who by
172 * definition are not logged in, so the default `manage_options` gate
173 * rejected every sample with a 401 and the feature could never record
174 * anything. (FBS-84070)
175 *
176 * Opting out drops ONLY the capability check. A route-declared
177 * `permission_callback` still runs and still has to pass, so a module can
178 * keep its own validation (nonce, rate limit, payload shape) on top.
179 */
180 private static function wrap_permission( Module $module, array $route ): callable {
181 $declared = $route['permission_callback'] ?? null;
182 $capability = $route['capability'] ?? 'manage_options';
183 $public = ! empty( $route['allow_unauthenticated'] );
184
185 return static function ( \WP_REST_Request $request ) use ( $declared, $capability, $public ) {
186 if ( ! $public && ! current_user_can( $capability ) ) {
187 return false;
188 }
189 if ( is_callable( $declared ) ) {
190 $result = call_user_func( $declared, $request );
191 if ( true !== $result ) {
192 return $result;
193 }
194 }
195 return true;
196 };
197 }
198 }
199