PluginProbe
MainWP Dashboard: Self-hosted WordPress Management for Agencies / 6.2.1
MainWP Dashboard: Self-hosted WordPress Management for Agencies v6.2.1
6.2.2 6.2.1 6.2 6.1.8 6.1.7 6.1.6 6.1.5 6.1.4 6.1.3 6.1.2 6.1.1 6.1 6.0.12 6.0.11 4.6.0.1 5.0 5.0.1 5.0.2 5.0.3 5.0.3.1 5.0.3.2 5.1 5.1.1 5.2 5.2.1 All 155 releases
mainwp / includes / abilities / class-mainwp-abilities.php

class-mainwp-abilities.php in MainWP Dashboard: Self-hosted WordPress Management for Agencies 6.2.1, at includes/abilities/class-mainwp-abilities.php

343 lines 11.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MainWP Abilities API Bootstrap
4 *
5 * @package MainWP\Dashboard
6 */
7
8 namespace MainWP\Dashboard;
9
10 if ( ! defined( 'ABSPATH' ) ) {
11 exit;
12 }
13
14 /**
15 * Class MainWP_Abilities
16 *
17 * Bootstraps the Abilities API integration for MainWP Dashboard.
18 * Feature-gated: does nothing if Abilities API is not available.
19 */
20 class MainWP_Abilities {
21
22 /**
23 * Maximum encoded GET input size.
24 *
25 * @var int
26 */
27 private const MAX_GET_INPUT_BYTES = 8192;
28
29 /**
30 * Maximum DELETE JSON body size.
31 *
32 * @var int
33 */
34 private const MAX_DELETE_INPUT_BYTES = 1048576;
35
36 /**
37 * Initialize the Abilities integration.
38 *
39 * Cron handlers are always initialized to process any previously queued jobs,
40 * even if the Abilities API is later disabled. The cron handler constructor
41 * is side-effect free beyond hooking actions, so this is safe.
42 *
43 * @return void
44 */
45 public static function init(): void {
46 // Always initialize cron handlers for batch processing.
47 // This ensures previously queued jobs are processed even if Abilities API
48 // is disabled after jobs were created. The cron handler only hooks actions
49 // and is safe to call without the Abilities API.
50 MainWP_Abilities_Cron::instance();
51
52 // Feature gate: If Abilities API is not available, skip ability registration.
53 if ( ! function_exists( 'wp_register_ability' ) ) {
54 return;
55 }
56
57 if ( false === has_filter( 'rest_request_before_callbacks', array( static::class, 'normalize_rest_input_transport' ) ) ) {
58 add_filter(
59 'rest_request_before_callbacks',
60 array( static::class, 'normalize_rest_input_transport' ),
61 5,
62 3
63 );
64 }
65
66 // Hook into MainWP REST authentication to include Abilities API routes.
67 // This allows consumer_key/consumer_secret authentication to work for abilities.
68 add_filter(
69 'mainwp_rest_is_request_to_rest_api',
70 array( static::class, 'include_abilities_in_rest_auth' )
71 );
72
73 add_action(
74 'wp_abilities_api_categories_init',
75 array( static::class, 'register_categories' )
76 );
77
78 add_action(
79 'wp_abilities_api_init',
80 array( static::class, 'register_abilities' )
81 );
82 }
83
84 /**
85 * Include Abilities API routes in MainWP REST authentication.
86 *
87 * This filter allows MainWP abilities to be accessed via both:
88 * 1. MainWP's consumer_key/consumer_secret API authentication
89 * 2. WordPress Application Passwords (standard REST auth)
90 *
91 * For Application Passwords to work, we must NOT force MainWP auth
92 * on Abilities API requests, as that would conflict with WP's native auth.
93 *
94 * @param bool $is_mainwp_api Whether the current request is to a MainWP REST API.
95 * @return bool True only for actual MainWP REST API requests (not abilities).
96 */
97 public static function include_abilities_in_rest_auth( bool $is_mainwp_api ): bool {
98 // Pass through the original detection - don't extend MainWP auth to Abilities API.
99 // Abilities API has its own permission_callback that checks current_user_can()
100 // which works with both MainWP API keys (via MainWP_REST_Authentication) and
101 // WordPress Application Passwords (via native WP REST auth).
102 return $is_mainwp_api;
103 }
104
105 /**
106 * Restore typed JSON values before the Abilities API validates a request.
107 *
108 * WordPress reads GET and DELETE ability input only from query parameters.
109 * Query parsing cannot distinguish JSON null from strings without a JSON
110 * carrier, and credentials must not be placed in DELETE URLs. This adapter
111 * is limited to MainWP ability run routes and leaves the legacy bracket-style
112 * input query parameter unchanged when no JSON carrier is present.
113 *
114 * @param mixed $response Result to send to the client, or null.
115 * @param array $handler Matched REST route handler.
116 * @param \WP_REST_Request $request Current REST request.
117 * @return mixed
118 */
119 public static function normalize_rest_input_transport( $response, array $handler, \WP_REST_Request $request ) {
120 unset( $handler );
121
122 if ( null !== $response ) {
123 return $response;
124 }
125
126 if ( ! preg_match( '#^/wp-abilities/v1/abilities/mainwp/[a-z0-9-]+/run$#', $request->get_route() ) ) {
127 return $response;
128 }
129
130 $method = strtoupper( $request->get_method() );
131 if ( 'GET' === $method ) {
132 return self::normalize_get_input_transport( $response, $request );
133 }
134
135 if ( 'DELETE' === $method ) {
136 return self::normalize_delete_input_transport( $response, $request );
137 }
138
139 return $response;
140 }
141
142 /**
143 * Normalize the GET input_json query carrier.
144 *
145 * @param mixed $response Result to send to the client, or null.
146 * @param \WP_REST_Request $request Current REST request.
147 * @return mixed
148 */
149 private static function normalize_get_input_transport( $response, \WP_REST_Request $request ) {
150 $query = $request->get_query_params();
151 if ( ! array_key_exists( 'input_json', $query ) ) {
152 return $response;
153 }
154
155 if ( array_key_exists( 'input', $query ) ) {
156 return self::invalid_input_transport();
157 }
158
159 $input = self::decode_input_object( $query['input_json'], self::MAX_GET_INPUT_BYTES );
160 if ( is_wp_error( $input ) ) {
161 return $input;
162 }
163
164 unset( $query['input_json'] );
165 $query['input'] = $input;
166 $request->set_query_params( $query );
167
168 return $response;
169 }
170
171 /**
172 * Normalize the DELETE application/json body carrier.
173 *
174 * @param mixed $response Result to send to the client, or null.
175 * @param \WP_REST_Request $request Current REST request.
176 * @return mixed
177 */
178 private static function normalize_delete_input_transport( $response, \WP_REST_Request $request ) {
179 $body = $request->get_body();
180 $query = $request->get_query_params();
181
182 if ( '' === trim( $body ) ) {
183 if ( array_key_exists( 'input_json', $query ) ) {
184 return self::invalid_input_transport();
185 }
186 return $response;
187 }
188
189 if ( array_key_exists( 'input', $query ) || array_key_exists( 'input_json', $query ) ) {
190 return self::invalid_input_transport();
191 }
192
193 $content_type = $request->get_content_type();
194 if ( ! is_array( $content_type ) || 'application/json' !== $content_type['value'] ) {
195 return self::invalid_input_transport();
196 }
197
198 if ( strlen( $body ) > self::MAX_DELETE_INPUT_BYTES ) {
199 return self::invalid_input_transport();
200 }
201
202 $shape = json_decode( $body, false, 32 );
203 $decoded = json_decode( $body, true, 32 );
204 if (
205 JSON_ERROR_NONE !== json_last_error()
206 || ! $shape instanceof \stdClass
207 || array( 'input' ) !== array_keys( get_object_vars( $shape ) )
208 || ! $shape->input instanceof \stdClass
209 || ! is_array( $decoded )
210 ) {
211 return self::invalid_input_transport();
212 }
213
214 $input = $decoded['input'];
215 if ( ! is_array( $input ) ) {
216 return self::invalid_input_transport();
217 }
218
219 $query['input'] = $input;
220 $request->set_query_params( $query );
221
222 return $response;
223 }
224
225 /**
226 * Decode a bounded JSON object without coercing scalar values.
227 *
228 * @param mixed $raw Raw JSON value.
229 * @param int $max_bytes Maximum encoded size.
230 * @return array|\WP_Error
231 */
232 private static function decode_input_object( $raw, int $max_bytes ) {
233 if ( ! is_string( $raw ) || strlen( $raw ) > $max_bytes ) {
234 return self::invalid_input_transport();
235 }
236
237 $decoded = json_decode( $raw, true, 32 );
238 if (
239 JSON_ERROR_NONE !== json_last_error()
240 || ! is_array( $decoded )
241 || '{' !== substr( ltrim( $raw ), 0, 1 )
242 ) {
243 return self::invalid_input_transport();
244 }
245
246 return $decoded;
247 }
248
249 /**
250 * Return the closed, non-reflective transport error.
251 *
252 * @return \WP_Error
253 */
254 private static function invalid_input_transport(): \WP_Error {
255 return new \WP_Error(
256 'mainwp_abilities_invalid_input_transport',
257 __( 'The ability input transport is invalid.', 'mainwp' ),
258 array( 'status' => 400 )
259 );
260 }
261
262 /**
263 * Register MainWP ability categories.
264 *
265 * @return void
266 */
267 public static function register_categories(): void {
268 if ( ! function_exists( 'wp_register_ability_category' ) ) {
269 return;
270 }
271
272 // Sites category.
273 wp_register_ability_category(
274 'mainwp-sites',
275 array(
276 'label' => __( 'MainWP Sites', 'mainwp' ),
277 'description' => __( 'Abilities for managing MainWP child sites including listing, syncing, and monitoring.', 'mainwp' ),
278 )
279 );
280
281 // Updates category.
282 wp_register_ability_category(
283 'mainwp-updates',
284 array(
285 'label' => __( 'MainWP Updates', 'mainwp' ),
286 'description' => __( 'Abilities for managing updates across MainWP child sites including core, plugins, and themes.', 'mainwp' ),
287 )
288 );
289
290 // Clients category.
291 wp_register_ability_category(
292 'mainwp-clients',
293 array(
294 'label' => __( 'MainWP Clients', 'mainwp' ),
295 'description' => __( 'Abilities for managing MainWP clients including creation, updates, and client-site relationships.', 'mainwp' ),
296 )
297 );
298
299 // Tags category.
300 wp_register_ability_category(
301 'mainwp-tags',
302 array(
303 'label' => __( 'MainWP Tags', 'mainwp' ),
304 'description' => __( 'Abilities for managing MainWP tags for organizing sites and clients.', 'mainwp' ),
305 )
306 );
307
308 // Batch category.
309 wp_register_ability_category(
310 'mainwp-batch',
311 array(
312 'label' => __( 'MainWP Batch Operations', 'mainwp' ),
313 'description' => __( 'Abilities for monitoring queued batch operations including sync, update, and site management tasks.', 'mainwp' ),
314 )
315 );
316 }
317
318 /**
319 * Register all MainWP abilities.
320 *
321 * @return void
322 */
323 public static function register_abilities(): void {
324 // Site abilities.
325 MainWP_Abilities_Sites::register();
326
327 // Update abilities.
328 MainWP_Abilities_Updates::register();
329
330 // Client abilities.
331 MainWP_Abilities_Clients::register();
332
333 // Tag abilities.
334 MainWP_Abilities_Tags::register();
335
336 // Batch abilities.
337 MainWP_Abilities_Batch::register();
338
339 // NOTE: Extensions register their own abilities in their own codebases.
340 // Total core abilities: 62 (30 sites + 13 updates + 11 clients + 7 tags + 1 batch).
341 }
342 }
343