PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.4
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.4
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / classes / api / rest-api.php

rest-api.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.4, at classes/api/rest-api.php

636 lines 17.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 - Handle MCP tool execution via REST API
4 *
5 * @package zip-ai
6 */
7
8 namespace ZipAI\Classes\Api;
9
10 // Exit if accessed directly.
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 use ZipAI\Classes\Core\Helper;
16 use ZipAI\Classes\Security\Protected_Options_Filter;
17
18 /**
19 * The REST_API Class.
20 * Handles REST API endpoints for MCP tool execution.
21 */
22 class REST_API {
23
24 /**
25 * Constructor of this class.
26 *
27 * @since 0.0.1
28 * @return void
29 */
30 public function __construct() {
31 add_action( 'rest_api_init', array( $this, 'register_routes' ) );
32
33 // Install the protected-options filters at WP's pre_update_option_<key>
34 // layer. This is the catch-all backstop for any code path — CLI, REST,
35 // AJAX, custom-plugin endpoints, code snippets — that calls
36 // update_option() while inside an MCP-bound request. The filters are
37 // registered once; the actual refusal is gated on the per-request
38 // `enter_mcp()` flag toggled below in `handle_mcp_request`.
39 Protected_Options_Filter::install();
40 }
41
42 /**
43 * Register REST API routes.
44 *
45 * @since 0.0.1
46 * @return void
47 */
48 public function register_routes() {
49 // Strict JSON-RPC 2.0 MCP Endpoint
50 register_rest_route(
51 'zip-ai/v1',
52 '/mcp',
53 array(
54 'methods' => 'POST',
55 'callback' => array( $this, 'handle_mcp_request' ),
56 'permission_callback' => array( $this, 'check_permission' ),
57 'args' => array(
58 'jsonrpc' => array(
59 'type' => 'string',
60 'required' => false,
61 'sanitize_callback' => 'sanitize_text_field',
62 ),
63 'id' => array(
64 'required' => false,
65 ),
66 'method' => array(
67 'type' => 'string',
68 'required' => true,
69 'sanitize_callback' => 'sanitize_text_field',
70 ),
71 'params' => array(
72 'type' => 'object',
73 'required' => false,
74 ),
75 ),
76 )
77 );
78
79 // Trigger site scan — sends raw site data to SaaS for memory enrichment.
80 register_rest_route(
81 'zip-ai/v1',
82 '/site-scan',
83 array(
84 'methods' => 'POST',
85 'callback' => array( $this, 'handle_site_scan' ),
86 'permission_callback' => array( $this, 'check_permission' ),
87 'args' => array(),
88 )
89 );
90
91 // Route for rendering block preview (POC)
92 register_rest_route(
93 'zip-ai/v1',
94 '/render-preview',
95 array(
96 'methods' => 'POST',
97 'callback' => array( $this, 'render_block_preview' ),
98 'permission_callback' => array( $this, 'check_permission' ),
99 'args' => array(
100 'block_html' => array(
101 'type' => 'string',
102 'required' => true,
103 // Do NOT run wp_kses_post here — Spectra and other
104 // block libraries emit inline `<style>` tags inside
105 // the block markup that the preview iframe needs to
106 // render correctly. The preview pipeline already
107 // passes input through `parse_blocks()` +
108 // `filter_valid_blocks()` (registered-only) and the
109 // resulting document is loaded into a sandboxed
110 // `<iframe srcDoc>` with a null origin.
111 'validate_callback' => static function ( $value ) {
112 return is_string( $value ) && '' !== trim( $value );
113 },
114 ),
115 ),
116 )
117 );
118 }
119
120 /**
121 * Single ingress point for all JSON-RPC 2.0 MCP requests.
122 *
123 * @param \WP_REST_Request $request The REST request object.
124 * @return \WP_REST_Response
125 */
126 public function handle_mcp_request( $request ) {
127 // Handle Authentication Context (from HTTP Headers/Session)
128 $this->setup_user_context( $request );
129
130 $body = $request->get_json_params();
131 $method = $body['method'] ?? null;
132 $id = $body['id'] ?? null;
133 $params = $body['params'] ?? array();
134
135 if ( empty( $method ) ) {
136 return $this->format_mcp_error( $id, -32600, 'Invalid Request: Missing method' );
137 }
138
139 // Mark this request as MCP-bound so the protected-options filters
140 // installed via Protected_Options_Filter::install() refuse mutations
141 // to site-critical keys (siteurl, home, template, …) regardless of
142 // which ability-specific code path tries to write them. Cleared in
143 // the `finally` block — `register_shutdown_function` is the safety
144 // net for fatal-error paths.
145 Protected_Options_Filter::enter_mcp();
146
147 try {
148 switch ( $method ) {
149 case 'initialize':
150 return $this->handle_initialize( $id );
151 case 'tools/list':
152 return $this->handle_tools_list( $id );
153 case 'tools/call':
154 return $this->handle_tools_call( $id, $params );
155 case 'notifications/initialized':
156 // Fire-and-forget notification, no response needed.
157 return new \WP_REST_Response( null, 200 );
158 default:
159 return $this->format_mcp_error( $id, -32601, "Method not found: {$method}" );
160 }
161 } catch ( \Throwable $e ) {
162 return $this->format_mcp_error( $id, -32000, 'Internal Server Error: ' . $e->getMessage() );
163 } finally {
164 Protected_Options_Filter::exit_mcp();
165 }
166 }
167
168 /**
169 * Handle MCP Initialization Protocol.
170 */
171 private function handle_initialize( $id ) {
172 return $this->format_mcp_response(
173 $id,
174 array(
175 'protocolVersion' => '2024-11-05',
176 'capabilities' => array(
177 'tools' => array(),
178 ),
179 'serverInfo' => array(
180 'name' => 'ZipWP WordPress MCP',
181 'version' => ZIP_AI_VERSION,
182 ),
183 )
184 );
185 }
186
187 /**
188 * Handle MCP Tools List Protocol.
189 */
190 private function handle_tools_list( $id ) {
191 if ( ! class_exists( 'WP_Abilities_Registry' ) ) {
192 return $this->format_mcp_error( $id, -32001, 'Abilities API is not available' );
193 }
194
195 $registry = \WP_Abilities_Registry::get_instance();
196 $abilities = $registry->get_all_registered();
197 $tools = array();
198
199 foreach ( $abilities as $ability_name => $ability ) {
200 $tool = array(
201 'name' => $ability->get_name() ?: $ability_name,
202 'description' => $ability->get_description(),
203 'inputSchema' => $ability->get_input_schema() ?: array(
204 'type' => 'object',
205 'properties' => new \stdClass(),
206 ),
207 );
208
209 // Expose output schema to the brain when declared — lets the LLM learn
210 // the tool's response contract from the schema rather than prose.
211 $output_schema = $ability->get_output_schema();
212 if ( ! empty( $output_schema ) ) {
213 $tool['outputSchema'] = $output_schema;
214 }
215
216 $label = $ability->get_label();
217 if ( ! empty( $label ) ) {
218 $tool['title'] = $label;
219 }
220
221 $tools[] = $tool;
222 }
223
224 return $this->format_mcp_response( $id, array( 'tools' => $tools ) );
225 }
226
227 /**
228 * Handle MCP Tools Call Protocol.
229 */
230 private function handle_tools_call( $id, $params ) {
231 $tool_name = $params['name'] ?? '';
232 $arguments = $params['arguments'] ?? array();
233
234 if ( empty( $tool_name ) ) {
235 return $this->format_mcp_error( $id, -32602, 'Invalid params: tool name required' );
236 }
237
238 if ( ! class_exists( 'WP_Abilities_Registry' ) ) {
239 return $this->format_mcp_error( $id, -32001, 'Abilities API is not available' );
240 }
241
242 $registry = \WP_Abilities_Registry::get_instance();
243 $ability = $registry->get_registered( $tool_name );
244
245 if ( ! $ability ) {
246 return $this->format_mcp_error( $id, -32601, "Tool not found: {$tool_name}" );
247 }
248
249 // Execute the tool — WP_Ability::execute() dispatches to the registered execute_callback
250 // (Abstract_Ability::handle_execute), which includes validation, rate-limiting, try-catch.
251 // Core's WP_Ability::execute() already runs the permission_callback registered
252 // against this ability (Ability_Loader.php:167) before invoking handle_execute,
253 // so we don't re-check here. Setup_user_context has already elevated the
254 // process to the bound WP user, so current_user_can() inside that callback
255 // returns the right answer.
256 $result = $ability->execute( $arguments );
257
258 // Check if it's already a standard response from our Response class (Response::success/error)
259 if ( is_array( $result ) && isset( $result['success'] ) ) {
260 if ( ! $result['success'] ) {
261 return $this->format_mcp_response(
262 $id,
263 array(
264 'content' => array(
265 array(
266 'type' => 'text',
267 'text' => wp_json_encode( $result ),
268 ),
269 ),
270 'isError' => true,
271 )
272 );
273 }
274
275 return $this->format_mcp_response(
276 $id,
277 array(
278 'content' => array(
279 array(
280 'type' => 'text',
281 'text' => wp_json_encode( $result ),
282 ),
283 ),
284 )
285 );
286 }
287
288 if ( is_wp_error( $result ) ) {
289 return $this->format_mcp_response(
290 $id,
291 array(
292 'content' => array(
293 array(
294 'type' => 'text',
295 'text' => wp_json_encode(
296 array(
297 'success' => false,
298 'error' => $result->get_error_message(),
299 'code' => $result->get_error_code(),
300 )
301 ),
302 ),
303 ),
304 'isError' => true,
305 )
306 );
307 }
308
309 return $this->format_mcp_response(
310 $id,
311 array(
312 'content' => array(
313 array(
314 'type' => 'text',
315 'text' => wp_json_encode(
316 array(
317 'success' => true,
318 'data' => $result,
319 )
320 ),
321 ),
322 ),
323 )
324 );
325 }
326
327 /**
328 * Format an MCP JSON-RPC standard response.
329 */
330 private function format_mcp_response( $id, $result ) {
331 return new \WP_REST_Response(
332 array(
333 'jsonrpc' => '2.0',
334 'id' => $id,
335 'result' => $result,
336 ),
337 200
338 );
339 }
340
341 /**
342 * Format an MCP JSON-RPC standard error.
343 */
344 private function format_mcp_error( $id, $code, $message ) {
345 return new \WP_REST_Response(
346 array(
347 'jsonrpc' => '2.0',
348 'id' => $id,
349 'error' => array(
350 'code' => $code,
351 'message' => $message,
352 ),
353 ),
354 200
355 );
356 }
357
358 /**
359 * Extracted user setup logic for Bearer authentication.
360 *
361 * The Bearer token is bound to a single WordPress user_id at issue time
362 * (`zip_ai_settings.auth_wp_user_id`). If no binding exists the request
363 * stays anonymous so downstream `current_user_can()` checks fail closed —
364 * the token alone never picks WHO to impersonate. The X-WP-User-ID header
365 * is honored only when it matches the stored binding.
366 */
367 private function setup_user_context( $request ) {
368 if ( ! $this->is_bearer_authenticated() ) {
369 return;
370 }
371
372 $mcp_settings = get_option( 'zip_ai_settings', array() );
373 $bound_user_id = isset( $mcp_settings['auth_wp_user_id'] ) ? absint( $mcp_settings['auth_wp_user_id'] ) : 0;
374
375 // Fail closed when the token is not bound to a specific WP user.
376 // Binding is established during `auth/exchange` (see Helper::exchange_token).
377 if ( $bound_user_id <= 0 ) {
378 return;
379 }
380
381 $claimed = $request->get_header( 'x_wp_user_id' )
382 ?? $request->get_param( 'wp_user_id' );
383 $claimed = $claimed ? absint( $claimed ) : 0;
384
385 // Claimed id, when supplied, must match the binding.
386 if ( $claimed > 0 && $claimed !== $bound_user_id ) {
387 return;
388 }
389
390 $user = get_user_by( 'id', $bound_user_id );
391 if ( $user ) {
392 wp_set_current_user( $user->ID );
393 }
394 }
395 /**
396 * Handle site scan — collects raw site data and sends to SaaS.
397 *
398 * @param \WP_REST_Request $request The REST request object.
399 * @return \WP_REST_Response
400 */
401 public function handle_site_scan( $request ) {
402 \ZipAI\Classes\Core\Site_Scanner::run_scan();
403
404 return new \WP_REST_Response(
405 array(
406 'success' => true,
407 'message' => 'Site scan sent.',
408 ),
409 200
410 );
411 }
412
413 /**
414 * Render a Gutenberg block preview as a standalone HTML document.
415 *
416 * The response body is a complete HTML page that the client loads into
417 * a sandboxed `<iframe srcDoc>` for isolated preview. This plugin does
418 * NOT emit `<script>` or `<style>` tags into the admin or frontend —
419 * those are inside the serialized preview document returned from this
420 * REST endpoint.
421 *
422 * The inline tags in the response body are captured by buffering
423 * `wp_head()` / `wp_footer()` output (produced by WordPress core's own
424 * `wp_enqueue_script` / `wp_enqueue_style` system on this very request)
425 * and re-serialising only those tags into the preview document. No
426 * user-supplied HTML reaches the `<script>`/`<style>` extraction path —
427 * the block input goes through `parse_blocks()` + `filter_valid_blocks()`
428 * + `render_block()` first.
429 *
430 * @param \WP_REST_Request $request The REST request.
431 * @return \WP_REST_Response
432 */
433 public function render_block_preview( $request ) {
434 $block_html = $request->get_param( 'block_html' );
435
436 // Safety check: Fix any remaining unicode escapes (should not happen after root cause fix).
437 if ( ! empty( $block_html ) && strpos( $block_html, 'u002d' ) !== false ) {
438 $block_html = str_replace( 'u002d', '-', $block_html );
439 }
440
441 if ( empty( $block_html ) ) {
442 return new \WP_REST_Response(
443 array(
444 'success' => false,
445 'error' => 'Block HTML is required',
446 ),
447 400
448 );
449 }
450
451 $blocks = parse_blocks( $block_html );
452
453 if ( empty( $blocks ) ) {
454 return new \WP_REST_Response(
455 array(
456 'success' => false,
457 'error' => 'No valid blocks found in the provided HTML',
458 ),
459 400
460 );
461 }
462
463 // Security: only allow registered blocks — prevents arbitrary HTML injection.
464 $blocks = $this->filter_valid_blocks( $blocks );
465
466 if ( empty( $blocks ) ) {
467 return new \WP_REST_Response(
468 array(
469 'success' => false,
470 'error' => 'No valid registered blocks found. Only registered WordPress blocks are allowed.',
471 ),
472 400
473 );
474 }
475
476 // Render the validated blocks via WordPress's own render pipeline.
477 $rendered_html = '';
478 foreach ( $blocks as $block ) {
479 $rendered_html .= render_block( $block );
480 }
481
482 // Capture the head + footer that WordPress core's enqueue system
483 // produces for this request. We then extract the already-enqueued
484 // `<link>` / `<style>` / `<script>` tags and serialise them into
485 // the preview document we return below. We are NOT emitting inline
486 // tags — we are re-packaging WP's own enqueue output so the iframe
487 // preview can render with the same styles/scripts that the front
488 // end would use.
489 ob_start();
490 wp_head();
491 $head_content = ob_get_clean();
492
493 preg_match_all( '/<link[^>]+stylesheet[^>]*>|<style[^>]*>.*?<\/style>|<script[^>]*>.*?<\/script>/is', $head_content, $matches );
494 $head_assets = implode( "\n", $matches[0] );
495
496 ob_start();
497 wp_footer();
498 $footer_content = ob_get_clean();
499
500 preg_match_all( '/<script[^>]*>.*?<\/script>/is', $footer_content, $footer_matches );
501 $footer_scripts = implode( "\n", $footer_matches[0] );
502
503 // Build the full HTML document for the iframe. This string is the
504 // REST response body (JSON field) — not echoed to any page.
505 $full_html = '<!DOCTYPE html><html><head><meta charset="UTF-8">' . $head_assets . '</head><body>' . $rendered_html . $footer_scripts . '</body></html>';
506
507 return new \WP_REST_Response(
508 array(
509 'success' => true,
510 'html' => $full_html,
511 ),
512 200
513 );
514 }
515
516 /**
517 * Blocks that require JavaScript and need placeholder in preview.
518 */
519 private const JS_DEPENDENT_BLOCKS = array(
520 'spectra/google-map',
521 'core/html',
522 );
523
524 /**
525 * Filter blocks to only include valid registered blocks.
526 *
527 * Recursively filters inner blocks as well.
528 * Removes any blocks that are not registered with WordPress.
529 *
530 * @param array $blocks Array of parsed blocks.
531 * @return array Filtered array of valid blocks.
532 */
533 private function filter_valid_blocks( $blocks ) {
534 $valid_blocks = array();
535 $block_registry = \WP_Block_Type_Registry::get_instance();
536 $registered_types = $block_registry->get_all_registered();
537
538 foreach ( $blocks as $block ) {
539 // Skip empty blocks (whitespace between blocks).
540 if ( empty( $block['blockName'] ) ) {
541 continue;
542 }
543
544 // Check if the block type is registered.
545 if ( ! isset( $registered_types[ $block['blockName'] ] ) ) {
546 // Skip unregistered blocks - potential XSS vector.
547 continue;
548 }
549
550 // Recursively filter inner blocks.
551 if ( ! empty( $block['innerBlocks'] ) ) {
552 $block['innerBlocks'] = $this->filter_valid_blocks( $block['innerBlocks'] );
553 }
554
555 $valid_blocks[] = $block;
556 }
557
558 return $valid_blocks;
559 }
560
561 /**
562 * Check if current request has permission to execute tools.
563 *
564 * Permission is granted if:
565 * 1. Request has valid Bearer token (dev token or stored auth token)
566 * 2. User is logged in with 'manage_options' capability
567 *
568 * @param \WP_REST_Request $request The REST request object.
569 * @return bool|\WP_Error True if permission granted, WP_Error otherwise.
570 */
571 public function check_permission( $request ) {
572 // Check Bearer token authentication.
573 if ( $this->is_bearer_authenticated() ) {
574 return true;
575 }
576
577 // Check WordPress user capabilities.
578 if ( current_user_can( 'manage_options' ) ) {
579 return true;
580 }
581
582 return new \WP_Error(
583 'rest_forbidden',
584 __( 'You do not have permission to execute tools.', 'zip-ai' ),
585 array( 'status' => 401 )
586 );
587 }
588
589 /**
590 * Check if the request is authenticated via Bearer token.
591 *
592 * Validates the Bearer token against the stored auth token
593 * from ZipWP authentication.
594 *
595 * @return bool True if authenticated, false otherwise.
596 */
597 private function is_bearer_authenticated() {
598 $token = $this->get_bearer_token();
599
600 if ( empty( $token ) ) {
601 return false;
602 }
603
604 // Check against stored auth token from ZipWP authentication.
605 $stored_token = Helper::get_decrypted_auth_token();
606 if ( $stored_token && hash_equals( $stored_token, $token ) ) {
607 return true;
608 }
609
610 return false;
611 }
612
613 /**
614 * Get Bearer token from Authorization header.
615 *
616 * @return string|false The token or false if not found.
617 */
618 private function get_bearer_token() {
619 $auth_header = isset( $_SERVER['HTTP_AUTHORIZATION'] )
620 ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) )
621 : '';
622
623 if ( empty( $auth_header ) && function_exists( 'getallheaders' ) ) {
624 $headers = getallheaders();
625 $raw_header = $headers['Authorization'] ?? $headers['authorization'] ?? '';
626 $auth_header = is_string( $raw_header ) ? sanitize_text_field( $raw_header ) : '';
627 }
628
629 if ( empty( $auth_header ) || strpos( $auth_header, 'Bearer ' ) !== 0 ) {
630 return false;
631 }
632
633 return substr( $auth_header, 7 );
634 }
635 }
636