PluginProbe ʕ •ᴥ•ʔ
Microsoft Clarity / 0.10.28
Microsoft Clarity v0.10.28
0.10.29 0.10.28 0.10.27 0.10.26 0.10.25 0.10.24 0.8.0 0.9.0 0.9.1 0.9.2 0.9.3 0.9.4 trunk 0.10.0 0.10.1 0.10.10 0.10.11 0.10.12 0.10.13 0.10.14 0.10.15 0.10.16 0.10.17 0.10.18 0.10.19 0.10.2 0.10.20 0.10.21 0.10.22 0.10.23 0.10.3 0.10.4 0.10.5 0.10.6 0.10.7 0.10.8 0.10.9 0.2 0.4 0.5 0.6 0.6.1 0.7 0.7.1 0.7.2 0.7.3 0.7.4 0.7.5
microsoft-clarity / includes / brandagent-endpoint.php
microsoft-clarity / includes Last commit date
webhooks 5 months ago brandagent-config.php 1 week ago brandagent-content-webhooks.php 1 week ago brandagent-custom-webhooks.php 3 months ago brandagent-endpoint.php 1 week ago brandagent-rest-api.php 3 months ago brandagent-webhooks.php 3 months ago brandagent-wordpress.php 1 week ago
brandagent-endpoint.php
515 lines
1 <?php
2 /**
3 * Brand Agent Endpoint Handler
4 *
5 * Handles proxying requests from the frontend to the BrandAgent backend server
6 * with HMAC authentication.
7 *
8 * @package MicrosoftClarity
9 * @since 0.10.21
10 */
11
12 // Exit if accessed directly
13 defined( 'ABSPATH' ) || exit;
14
15 /**
16 * Brand Agent Endpoint Handler Class
17 */
18 class BrandAgent_Endpoint {
19
20 /**
21 * Handle incoming API requests
22 */
23 public static function handle_request() {
24 $path = get_query_var( 'brandagent_path' );
25
26 if ( $path === 'api/config/read' ) {
27 self::handle_config_read();
28 }
29
30 if ( $path === 'api/v1/init' ) {
31 self::handle_init();
32 }
33
34 if ( $path === 'api/config/update' ) {
35 self::handle_config_update();
36 }
37
38 if ( $path === 'api/config/status' ) {
39 self::handle_config_status();
40 }
41
42 if ( $path === 'api/content/fetch' ) {
43 self::handle_content_fetch();
44 }
45 }
46
47 /**
48 * Build the outbound query suffix for a proxied backend call.
49 *
50 * clientInformation is handled separately because http_build_query() would double-escape the
51 * JSON; it is unescaped once, re-encoded, then encoded exactly once. The suffix is built here
52 * so the outbound URL and the signed backend path are always derived from the same string:
53 * the plain-WordPress signature covers path + query exactly as the BA server receives it, so
54 * the two must never drift.
55 *
56 * @param array $query_params Raw request query parameters (typically $_GET).
57 * @return string Query suffix, already URL-encoded.
58 */
59 private static function build_query_suffix( array $query_params ) {
60 // Handle clientInformation separately to avoid double escaping
61 $client_info = null;
62 if ( isset( $query_params['clientInformation'] ) ) {
63 // Get the raw value and clean it up
64 $raw_client_info = $query_params['clientInformation'];
65
66 // Remove any existing escaping
67 $clean_json = stripslashes( $raw_client_info );
68
69 // Validate it's valid JSON
70 $decoded = json_decode( $clean_json, true );
71 if ( $decoded !== null ) {
72 // Re-encode as clean JSON
73 $clean_json = json_encode( $decoded, JSON_UNESCAPED_SLASHES );
74 }
75
76 // URL encode it properly
77 $client_info = rawurlencode( $clean_json );
78 unset( $query_params['clientInformation'] ); // Remove from main query
79 }
80
81 // Build the main query without clientInformation, then append it. The separator is derived
82 // rather than hard-coded: both handlers reject a request without clientId, so the query is
83 // never empty here today, but a hard-coded '&' would silently emit '/path&clientInformation='
84 // if that ever changed, corrupting the URL and the signed path with it. null vs '' matters,
85 // so a present-but-empty clientInformation still emits a bare 'clientInformation='.
86 $query_suffix = '';
87 if ( ! empty( $query_params ) ) {
88 $query_suffix = '?' . http_build_query( $query_params );
89 }
90 if ( $client_info !== null ) {
91 $query_suffix .= ( $query_suffix === '' ? '?' : '&' ) . 'clientInformation=' . $client_info;
92 }
93
94 return $query_suffix;
95 }
96
97 /**
98 * Build the outbound backend URL and the auth headers for a proxied GET.
99 *
100 * A plain-WordPress site's per-site secret is filed in Key Vault under
101 * wordpress-{site}-hmac-secret. The X-WooCommerce-* headers select the backend's
102 * WooCommerceHmac scheme, which only ever resolves woocommerce-{site}-hmac-secret and so
103 * fails closed on a site that never ran the wc-auth flow. WooCommerce stores must keep the
104 * original scheme: their secret really is filed under the WooCommerce name.
105 *
106 * @param string $endpoint_path Backend path, e.g. '/api/config/read'.
107 * @param array $base_headers Endpoint-specific headers (Accept, Content-Type, ...).
108 * @param array $query_params Raw request query parameters (typically $_GET).
109 * @param string $client_id Client id, WooCommerce scheme only.
110 * @param string $store_url Store URL, WooCommerce scheme only.
111 * @param string $signature Signature, WooCommerce scheme only.
112 * @param int $timestamp Timestamp, WooCommerce scheme only.
113 * @return array|WP_Error Array with 'url' and 'headers', or WP_Error when signing fails.
114 */
115 private static function build_backend_request( $endpoint_path, array $base_headers, array $query_params, $client_id, $store_url, $signature, $timestamp ) {
116 $query_suffix = self::build_query_suffix( $query_params );
117 $headers = $base_headers;
118
119 // Scheme follows the stored credential, not the current WooCommerce load state, so that
120 // deactivating WooCommerce on an onboarded store cannot start signing with the wrong scheme.
121 if ( brandagent_get_hmac_platform() !== 'woocommerce' ) {
122 $wp_headers = brandagent_wordpress_build_signed_headers( $endpoint_path . $query_suffix, '', 'GET' );
123 if ( is_wp_error( $wp_headers ) ) {
124 return $wp_headers;
125 }
126 $headers = array_merge( $headers, $wp_headers );
127 } else {
128 $headers['X-WooCommerce-Client-Id'] = $client_id;
129 $headers['X-WooCommerce-Store-Url'] = $store_url;
130 $headers['X-WooCommerce-Signature'] = $signature;
131 $headers['X-WooCommerce-Timestamp'] = (string) $timestamp;
132 }
133
134 // Add any existing headers from the original request that might be needed
135 if ( isset( $_SERVER['HTTP_ACCEPT'] ) ) {
136 $headers['Accept'] = $_SERVER['HTTP_ACCEPT'];
137 }
138 if ( isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
139 $headers['User-Agent'] = $_SERVER['HTTP_USER_AGENT'];
140 }
141
142 return array(
143 'url' => BrandAgent_Config::get_backend_base_url() . $endpoint_path . $query_suffix,
144 'headers' => $headers,
145 );
146 }
147
148 /**
149 * Handle api/config/read endpoint
150 * Proxies config requests to the BrandAgent backend
151 */
152 private static function handle_config_read() {
153 // Get HMAC secret for this specific store (decrypted from wp_options)
154 $store_url = home_url();
155 $secret_key = brandagent_get_hmac_secret();
156
157 if ( ! $secret_key ) {
158 brandagent_log( 'BrandAgent Config Read: HMAC secret missing' );
159 wp_send_json_error( array( 'message' => 'HMAC secret not found. Please complete onboarding.' ), 401 );
160 }
161
162 // clientId presence is required; the widget always sends it.
163 $client_id = brandagent_get_client_id();
164 if ( ! $client_id ) {
165 brandagent_log( 'BrandAgent Config Read: Missing clientId parameter' );
166 wp_send_json_error( array( 'message' => 'No clientId provided' ), 400 );
167 }
168
169 $timestamp = time();
170 $signature = brandagent_generate_hmac_signature( $client_id, $timestamp, $secret_key );
171
172 // URL and auth headers come from the shared builder so this handler and handle_init()
173 // cannot drift apart on the signing contract.
174 $request = self::build_backend_request(
175 '/api/config/read',
176 array(
177 'Content-Type' => 'application/json',
178 'Accept' => 'application/json',
179 'User-Agent' => 'BrandAgent-WordPress-Plugin/1.0',
180 'ngrok-skip-browser-warning' => 'true', // Bypass ngrok browser warning
181 ),
182 $_GET,
183 $client_id,
184 $store_url,
185 $signature,
186 $timestamp
187 );
188
189 if ( is_wp_error( $request ) ) {
190 brandagent_log( 'BrandAgent Config Read: Unable to sign WordPress request', array( 'error' => $request->get_error_message() ) );
191 wp_send_json_error( array( 'message' => 'HMAC secret not found. Please complete onboarding.' ), 401 );
192 }
193
194 $config_response = wp_remote_get(
195 $request['url'],
196 array(
197 'timeout' => 30,
198 'headers' => $request['headers'],
199 )
200 );
201
202 if ( is_wp_error( $config_response ) ) {
203 brandagent_log( 'BrandAgent Config Read: Failed to get client config', array( 'error' => $config_response->get_error_message() ) );
204 wp_send_json_error( array( 'message' => 'Failed to get client configuration' ), 502 );
205 }
206
207 $config_status_code = wp_remote_retrieve_response_code( $config_response );
208 $config_body = wp_remote_retrieve_body( $config_response );
209
210 if ( $config_status_code === 200 ) {
211 // Return the actual client configuration from ConfigController
212 header( 'Content-Type: application/json' );
213 echo $config_body;
214 exit;
215 } else {
216 brandagent_log( 'BrandAgent Config Read: Backend returned non-success status', array( 'status_code' => $config_status_code ) );
217 wp_send_json_error( array( 'message' => 'Failed to retrieve configuration' ), $config_status_code );
218 }
219 }
220
221 /**
222 * Handle api/v1/init endpoint
223 * Proxies SSE stream requests to the BrandAgent backend
224 */
225 private static function handle_init() {
226 // Get HMAC secret for this specific store (decrypted from wp_options)
227 $store_url = home_url();
228 $secret_key = brandagent_get_hmac_secret();
229
230 if ( ! $secret_key ) {
231 brandagent_log( 'BrandAgent Init: HMAC secret missing' );
232 wp_send_json_error( array( 'message' => 'HMAC secret not found. Please complete onboarding.' ), 401 );
233 }
234
235 // clientId presence is required; the widget always sends it.
236 $client_id = brandagent_get_client_id();
237 if ( ! $client_id ) {
238 brandagent_log( 'BrandAgent Init: Missing clientId parameter' );
239 wp_send_json_error( array( 'message' => 'No clientId provided' ), 400 );
240 }
241
242 $timestamp = time();
243 $signature = brandagent_generate_hmac_signature( $client_id, $timestamp, $secret_key );
244
245 // Shared builder, as in handle_config_read(). For a plain-WordPress site the X-WordPress-*
246 // scheme also routes the request to the document orchestration path, which is the correct
247 // agent for a content site. WooCommerce stores are unchanged.
248 $request = self::build_backend_request(
249 '/api/v1/init',
250 array(
251 'Accept' => 'text/event-stream',
252 'Cache-Control' => 'no-cache',
253 'User-Agent' => 'BrandAgent-WordPress-Plugin/1.0',
254 'ngrok-skip-browser-warning' => 'true', // Bypass ngrok browser warning
255 ),
256 $_GET,
257 $client_id,
258 $store_url,
259 $signature,
260 $timestamp
261 );
262
263 if ( is_wp_error( $request ) ) {
264 brandagent_log( 'BrandAgent Init: Unable to sign WordPress request', array( 'error' => $request->get_error_message() ) );
265 wp_send_json_error( array( 'message' => 'HMAC secret not found. Please complete onboarding.' ), 401 );
266 }
267
268 $init_response = wp_remote_get(
269 $request['url'],
270 array(
271 'timeout' => 30,
272 'headers' => $request['headers'],
273 )
274 );
275
276 if ( is_wp_error( $init_response ) ) {
277 brandagent_log( 'BrandAgent Init: Failed to initialize chat', array( 'error' => $init_response->get_error_message() ) );
278 wp_send_json_error( array( 'message' => 'Failed to initialize chat' ), 502 );
279 }
280
281 $init_status_code = wp_remote_retrieve_response_code( $init_response );
282 $init_body = wp_remote_retrieve_body( $init_response );
283
284 if ( $init_status_code === 200 ) {
285 // Set SSE headers for the response
286 header( 'Content-Type: text/event-stream' );
287 header( 'Cache-Control: no-cache' );
288 header( 'Connection: keep-alive' );
289
290 echo $init_body;
291 exit;
292 } else {
293 brandagent_log( 'BrandAgent Init: Backend returned non-success status', array( 'status_code' => $init_status_code ) );
294 wp_send_json_error( array( 'message' => 'Failed to initialize chat' ), $init_status_code );
295 }
296 }
297
298 /**
299 * Handle api/config/update endpoint
300 * Receives configuration updates from the backend server
301 */
302 private static function handle_config_update() {
303 // Prevent caching of this state-changing endpoint
304 header( 'Cache-Control: no-store' );
305
306 // Get authentication headers
307 $signature = isset( $_SERVER['HTTP_X_BA_SIGNATURE'] )
308 ? sanitize_text_field( $_SERVER['HTTP_X_BA_SIGNATURE'] )
309 : '';
310 $timestamp = isset( $_SERVER['HTTP_X_BA_TIMESTAMP'] )
311 ? sanitize_text_field( $_SERVER['HTTP_X_BA_TIMESTAMP'] )
312 : '';
313 $store_url_header = isset( $_SERVER['HTTP_X_BA_STORE_URL'] )
314 ? sanitize_text_field( $_SERVER['HTTP_X_BA_STORE_URL'] )
315 : '';
316
317 // Validate required headers present
318 if ( empty( $signature ) || empty( $timestamp ) || empty( $store_url_header ) ) {
319 brandagent_log( 'BrandAgent Config Update: Missing required authentication headers' );
320 wp_send_json_error( array( 'message' => 'Missing authentication headers' ), 401 );
321 }
322
323 // Verify store URL matches this site
324 if ( $store_url_header !== home_url() ) {
325 brandagent_log( 'BrandAgent Config Update: Store URL mismatch', array(
326 'expected_store_url' => home_url(),
327 'received_store_url' => $store_url_header,
328 ) );
329 wp_send_json_error( array( 'message' => 'Store URL mismatch' ), 403 );
330 }
331
332 // Read BAInjectFrontendScript from query parameter (GET) or JSON body (POST, legacy)
333 $ba_value = null;
334 $hmac_payload = '';
335 if ( isset( $_GET['BAInjectFrontendScript'] ) ) {
336 $ba_value = sanitize_text_field( $_GET['BAInjectFrontendScript'] );
337 // HMAC signs the query string (same string the C# sender hashes)
338 $hmac_payload = 'BAInjectFrontendScript=' . $ba_value;
339 } elseif ( $_SERVER['REQUEST_METHOD'] === 'POST' ) {
340 // Legacy POST support for backward compatibility during rollout
341 $hmac_payload = file_get_contents( 'php://input' );
342 $data = json_decode( $hmac_payload, true );
343 if ( json_last_error() === JSON_ERROR_NONE && isset( $data['BAInjectFrontendScript'] ) ) {
344 $ba_value = $data['BAInjectFrontendScript'] === true || $data['BAInjectFrontendScript'] === 'true' ? 'true' : 'false';
345 }
346 }
347
348 if ( $ba_value === null ) {
349 brandagent_log( 'BrandAgent Config Update: Missing BAInjectFrontendScript parameter', array( 'method' => $_SERVER['REQUEST_METHOD'] ?? '' ) );
350 wp_send_json_error( array( 'message' => 'Missing BAInjectFrontendScript parameter' ), 400 );
351 }
352
353 // Verify HMAC signature
354 if ( ! brandagent_verify_incoming_hmac_signature( $signature, $timestamp, $hmac_payload ) ) {
355 brandagent_log( 'BrandAgent Config Update: HMAC signature verification failed' );
356 wp_send_json_error( array( 'message' => 'Invalid signature' ), 401 );
357 }
358
359 // Handle BAInjectFrontendScript update
360 $new_value = ( $ba_value === 'true' );
361 update_option( 'BAInjectFrontendScript', $new_value ? 'true' : 'false' );
362
363 brandagent_log( 'BrandAgent Config Update: BAInjectFrontendScript updated', array(
364 'new_value' => $new_value ? 'true' : 'false',
365 ) );
366
367 // Create webhooks once when inject=true AND OAuth has succeeded.
368 if ( $new_value
369 && get_option( 'BAOauthSuccess' ) == 1
370 && ! get_option( 'BAWebhooksCreated' ) ) {
371 // BA server has already handled complete-onboarding via PublishAgent.
372 // The plugin's only job here is to register WooCommerce webhooks.
373 if ( class_exists( 'BrandAgent_Webhooks' ) ) {
374 $results = BrandAgent_Webhooks::create_webhooks();
375 $webhook_count = is_array( $results ) ? count( $results ) : 0;
376 $success_count = is_array( $results ) ? count( array_filter( $results ) ) : 0;
377 $failure_count = $webhook_count - $success_count;
378 $all_succeeded = ( 0 < $webhook_count && 0 === $failure_count );
379 if ( $all_succeeded ) {
380 update_option( 'BAWebhooksCreated', true );
381 brandagent_log( 'BrandAgent Config Update: Webhooks created on store approval', array(
382 'webhook_count' => $webhook_count,
383 'success_count' => $success_count,
384 'failure_count' => $failure_count,
385 ) );
386 } else {
387 // Do NOT persist BAWebhooksCreated on partial/failed creation so future attempts can retry.
388 brandagent_log( 'BrandAgent Config Update: Webhook creation incomplete; will retry on next update', array(
389 'webhook_count' => $webhook_count,
390 'success_count' => $success_count,
391 'failure_count' => $failure_count,
392 ) );
393 }
394 } else {
395 brandagent_log( 'BrandAgent Config Update: BrandAgent_Webhooks class not available for store approval webhook creation' );
396 }
397 } else {
398 brandagent_log( 'BrandAgent Config Update: No onboarding side effects required', array(
399 'new_value' => $new_value ? 'true' : 'false',
400 'oauth_success' => get_option( 'BAOauthSuccess' ) == 1,
401 ) );
402 }
403
404 wp_send_json_success( array(
405 'message' => 'Configuration updated',
406 'BAInjectFrontendScript' => $new_value ? 'true' : 'false'
407 ) );
408 }
409
410 /**
411 * Handle api/content/fetch endpoint
412 *
413 * Serves the site's own content (posts/pages) to the BrandAgent backend for
414 * indexing. Backend-to-plugin call, authenticated with the same X-BA-* inbound
415 * HMAC contract as config/update (signature over store_url + timestamp + sha256(raw body)).
416 * Content is read server-side via WP_Query, so it is reachable regardless of which
417 * post types opt into the public REST API and works on any WordPress (no WooCommerce).
418 */
419 private static function handle_content_fetch() {
420 header( 'Cache-Control: no-store' );
421
422 $signature = isset( $_SERVER['HTTP_X_BA_SIGNATURE'] )
423 ? sanitize_text_field( $_SERVER['HTTP_X_BA_SIGNATURE'] )
424 : '';
425 $timestamp = isset( $_SERVER['HTTP_X_BA_TIMESTAMP'] )
426 ? sanitize_text_field( $_SERVER['HTTP_X_BA_TIMESTAMP'] )
427 : '';
428 $store_url_header = isset( $_SERVER['HTTP_X_BA_STORE_URL'] )
429 ? sanitize_text_field( $_SERVER['HTTP_X_BA_STORE_URL'] )
430 : '';
431
432 if ( empty( $signature ) || empty( $timestamp ) || empty( $store_url_header ) ) {
433 brandagent_log( 'BrandAgent Content Fetch: Missing required authentication headers' );
434 wp_send_json_error( array( 'message' => 'Missing authentication headers' ), 401 );
435 }
436
437 if ( $store_url_header !== home_url() ) {
438 brandagent_log( 'BrandAgent Content Fetch: Store URL mismatch', array(
439 'expected_store_url' => home_url(),
440 'received_store_url' => $store_url_header,
441 ) );
442 wp_send_json_error( array( 'message' => 'Store URL mismatch' ), 403 );
443 }
444
445 $raw_body = file_get_contents( 'php://input' );
446 if ( ! brandagent_verify_incoming_hmac_signature( $signature, $timestamp, (string) $raw_body ) ) {
447 brandagent_log( 'BrandAgent Content Fetch: HMAC signature verification failed' );
448 wp_send_json_error( array( 'message' => 'Invalid signature' ), 401 );
449 }
450
451 $req = json_decode( (string) $raw_body, true );
452 if ( ! is_array( $req ) ) {
453 $req = array();
454 }
455
456 // Restrict to an explicit allowlist so a caller cannot pull private or PII-bearing
457 // custom post types through WP_Query. Sites can widen it via the filter.
458 $allowed_types = apply_filters( 'brandagent_content_fetch_allowed_post_types', array( 'post', 'page' ) );
459 $requested_types = ( isset( $req['types'] ) && is_array( $req['types'] ) )
460 ? array_values( array_map( 'sanitize_key', $req['types'] ) )
461 : array();
462 $types = array_values( array_intersect( $requested_types, $allowed_types ) );
463 if ( empty( $types ) ) {
464 $types = $allowed_types;
465 }
466 $page = isset( $req['page'] ) ? max( 1, intval( $req['page'] ) ) : 1;
467 $per_page = isset( $req['per_page'] ) ? min( 100, max( 1, intval( $req['per_page'] ) ) ) : 50;
468
469 $query = new WP_Query( array(
470 'post_type' => $types,
471 'post_status' => 'publish',
472 'posts_per_page' => $per_page,
473 'paged' => $page,
474 'orderby' => 'ID',
475 'order' => 'ASC',
476 'ignore_sticky_posts' => true,
477 'has_password' => false,
478 ) );
479
480 $items = array();
481 foreach ( $query->posts as $post ) {
482 // Shared builder (includes/brandagent-content-webhooks.php) — the single source of truth for
483 // the content item shape, so the bulk fetch here and the incremental webhooks stay identical.
484 $items[] = brandagent_build_content_item( $post );
485 }
486
487 wp_send_json_success( array(
488 'page' => $page,
489 'per_page' => $per_page,
490 'total' => (int) $query->found_posts,
491 'total_pages' => (int) $query->max_num_pages,
492 'count' => count( $items ),
493 'items' => $items,
494 ) );
495 }
496
497 /**
498 * Handle api/config/status endpoint
499 * Returns current configuration values (read-only, no auth required)
500 */
501 private static function handle_config_status() {
502 $ba_inject_enabled = get_option( 'BAInjectFrontendScript', 'false' );
503 $ba_oauth_success = get_option( 'BAOauthSuccess', '0' );
504
505 wp_send_json_success( array(
506 'BAInjectFrontendScript' => $ba_inject_enabled,
507 'BAOauthSuccess' => $ba_oauth_success,
508 'pluginVersion' => get_installed_plugin_version(),
509 ) );
510 }
511 }
512
513 // Run the handler immediately when file is included
514 BrandAgent_Endpoint::handle_request();
515