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

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

177 lines 6.2 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 $routes = $module->rest_routes();
35 if ( empty( $routes ) ) {
36 return;
37 }
38
39 add_action(
40 'rest_api_init',
41 static function () use ( $module, $routes ) {
42 $slug = $module->slug();
43 foreach ( $routes as $route ) {
44 $path = '/' . trim( $slug, '/' ) . '/' . ltrim( $route['path'] ?? '', '/' );
45 $path = rtrim( $path, '/' );
46
47 $args = array(
48 'methods' => $route['methods'] ?? 'GET',
49 'callback' => self::wrap_callback( $module, $route ),
50 'permission_callback' => self::wrap_permission( $module, $route ),
51 );
52 if ( isset( $route['args'] ) ) {
53 $args['args'] = $route['args'];
54 }
55
56 register_rest_route( self::NAMESPACE_V1, $path, $args );
57 }
58 }
59 );
60 }
61
62 /**
63 * Wrap a module's callback with the tier + conflict gates.
64 */
65 private static function wrap_callback( Module $module, array $route ): callable {
66 $callback = $route['callback'] ?? null;
67 $feature = $route['feature'] ?? null; // optional sub-feature key for conflict resolution.
68
69 return static function ( \WP_REST_Request $request ) use ( $module, $callback, $feature ) {
70 // Tier gate — Pro route without active Pro plugin looks like it doesn't exist.
71 if ( ! Tier_Registry::is_available( $module ) ) {
72 return new \WP_Error(
73 'rest_no_route',
74 __( 'No route was found matching the URL and request method.', 'xspeed' ),
75 array( 'status' => 404 )
76 );
77 }
78
79 // Conflict gate — refuse-strategy → 409.
80 if ( $feature ) {
81 $reason = Conflict_Registry::why_blocked( $module->slug(), $feature );
82 if ( $reason ) {
83 return new \WP_Error(
84 'xspeed_conflict_refused',
85 $reason,
86 array( 'status' => 409 )
87 );
88 }
89 }
90
91 if ( ! is_callable( $callback ) ) {
92 return new \WP_Error( 'xspeed_no_callback', 'Module REST callback is not callable.', array( 'status' => 500 ) );
93 }
94
95 return self::run_isolated( $callback, $request, $module->slug() );
96 };
97 }
98
99 /**
100 * Backstop for every module REST callback. Containment, not a substitute
101 * for per-query guards: callbacks should still avoid failing queries.
102 *
103 * Two things leak non-JSON into a REST body and break the client's
104 * JSON.parse ("Unexpected token '<'"):
105 * 1. $wpdb echoes "WordPress database error: …" as an HTML <div> the
106 * instant a query fails, when display-errors is on (common on the
107 * hosts we ship to). That HTML is flushed BEFORE our handler
108 * returns, so a try/catch around the return value can't catch it.
109 * We suppress $wpdb's echo for the duration of the call (errors are
110 * still logged) and capture any other stray output via an output
111 * buffer, discarding it so only our JSON reaches the client.
112 * 2. A thrown Throwable would surface as a fatal/HTML error page. We
113 * convert it to a clean 500 WP_Error.
114 *
115 * $wpdb's prior show-errors state and the buffer are always restored in
116 * finally, so global state is untouched after the call.
117 */
118 private static function run_isolated( callable $callback, \WP_REST_Request $request, string $slug ) {
119 global $wpdb;
120
121 $prev_show_errors = null;
122 if ( $wpdb instanceof \wpdb ) {
123 // hide_errors() returns the previous flag so we can restore it.
124 $prev_show_errors = $wpdb->hide_errors();
125 }
126
127 ob_start();
128 try {
129 $result = call_user_func( $callback, $request );
130 } catch ( \Throwable $e ) {
131 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
132 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.
133 }
134 $result = new \WP_Error(
135 'xspeed_rest_exception',
136 __( 'The request could not be completed due to a server error.', 'xspeed' ),
137 array( 'status' => 500 )
138 );
139 } finally {
140 // Discard anything the callback (or $wpdb) echoed — DB-error
141 // HTML, notices, debug output — so the response body is JSON only.
142 $stray = ob_get_clean();
143 if ( '' !== $stray && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
144 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.
145 }
146 if ( $wpdb instanceof \wpdb && false !== $prev_show_errors && null !== $prev_show_errors ) {
147 $wpdb->show_errors();
148 }
149 }
150
151 return $result;
152 }
153
154 /**
155 * Wrap permission_callback with the always-on cap check. A module may
156 * declare its own permission_callback for an extra-strict gate; both
157 * must pass.
158 */
159 private static function wrap_permission( Module $module, array $route ): callable {
160 $declared = $route['permission_callback'] ?? null;
161 $capability = $route['capability'] ?? 'manage_options';
162
163 return static function ( \WP_REST_Request $request ) use ( $declared, $capability ) {
164 if ( ! current_user_can( $capability ) ) {
165 return false;
166 }
167 if ( is_callable( $declared ) ) {
168 $result = call_user_func( $declared, $request );
169 if ( true !== $result ) {
170 return $result;
171 }
172 }
173 return true;
174 };
175 }
176 }
177