PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.7
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.7
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 / core / helper.php

helper.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.7, at classes/core/helper.php

805 lines 26.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * ZIP AI - Helper.
4 *
5 * This file contains the helper functions of ZIP AI.
6 * Helpers are functions that are used throughout the library.
7 *
8 * @package zip-ai
9 */
10
11 namespace ZipAI\MCP\Classes\Core;
12
13 // Exit if accessed directly.
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 // Classes to be used, in alphabetical order.
19 use ZipAI\MCP\Classes\Core\Utils;
20
21 /**
22 * The Helper Class.
23 */
24 class Helper {
25
26 /**
27 * Check if SSL verification should be enabled for remote requests.
28 *
29 * SSL verification is enabled by default for security. It can be disabled
30 * for local development environments using the ZIPAI_MCP_DISABLE_SSL_VERIFY
31 * constant or the 'zip_ai_sslverify' filter.
32 *
33 * @since 1.0.0
34 * @return bool True if SSL should be verified, false otherwise.
35 */
36 public static function should_verify_ssl() {
37 // Default to true (SSL verification enabled) for security.
38 $verify_ssl = true;
39
40 // Allow disabling via constant for local development.
41 if ( defined( 'ZIPAI_MCP_DISABLE_SSL_VERIFY' ) && ZIPAI_MCP_DISABLE_SSL_VERIFY ) {
42 $verify_ssl = false;
43 }
44
45 /**
46 * Filter whether SSL verification should be enabled for remote requests.
47 *
48 * @since 1.0.0
49 * @param bool $verify_ssl Whether to verify SSL. Default true.
50 */
51 return apply_filters( 'zip_ai_sslverify', $verify_ssl );
52 }
53
54 /**
55 * Get an option from the database.
56 *
57 * @param string $key The option key.
58 * @param mixed $default The option default value if option is not available.
59 * @param boolean $network_override Whether to allow the network admin setting to be overridden on subsites.
60 * @since 1.0.0
61 * @return mixed The option value.
62 */
63 public static function get_admin_settings_option( $key, $default = false, $network_override = false ) {
64 // Get the site-wide option if we're in the network admin.
65 return $network_override && is_multisite() ? get_site_option( $key, $default ) : get_option( $key, $default );
66 }
67
68 /**
69 * Update an option from the database.
70 *
71 * @param string $key The option key.
72 * @param mixed $value The value to update.
73 * @param bool $network_override Whether to allow the network_override admin setting to be overridden on subsites.
74 * @since 1.0.0
75 * @return bool True if the option was updated, false otherwise.
76 */
77 public static function update_admin_settings_option( $key, $value, $network_override = false ) {
78 // Update the site-wide option if we're in the network admin, and return the updated status.
79 return $network_override && is_multisite() ? update_site_option( $key, $value ) : update_option( $key, $value );
80 }
81
82 /**
83 * Check if ZIP AI is authorized.
84 *
85 * @since 1.0.0
86 * @return boolean True if ZIP AI is authorized, false otherwise.
87 */
88 public static function is_authorized() {
89 // Check zip_mcp_settings for auth_token.
90 $auth_token = self::get_decrypted_auth_token();
91
92 return ! empty( $auth_token ) && is_string( $auth_token ) && ! empty( trim( $auth_token ) );
93 }
94
95 /**
96 * Get the ZIP AI Settings.
97 *
98 * If used with a key, it will return that specific setting.
99 * If used without a key, it will return the entire settings array.
100 *
101 * @param string $key The setting key.
102 * @param mixed $default The default value to return if the setting is not found.
103 * @since 1.0.0
104 * @return mixed|array The setting value, or the default.
105 */
106 public static function get_setting( $key = '', $default = array() ) {
107
108 // Get the ZIP AI settings.
109 $existing_settings = self::get_admin_settings_option( 'zip_mcp_settings' );
110
111 // If the ZIP AI settings are empty, return the fallback.
112 if ( empty( $existing_settings ) || ! is_array( $existing_settings ) ) {
113 return $default;
114 }
115
116 // If the key is empty, return the entire settings array - otherwise return the specific setting or the fallback.
117 if ( empty( $key ) ) {
118 return $existing_settings;
119 } else {
120 return isset( $existing_settings[ $key ] ) ? $existing_settings[ $key ] : $default;
121 }
122 }
123
124 /**
125 * Get the decrypted auth token from zip_mcp_settings.
126 *
127 * @since 1.0.0
128 * @return string The decrypted auth token.
129 */
130 public static function get_decrypted_auth_token() {
131 static $resolved_token = null;
132 static $is_resolved = false;
133
134 if ( $is_resolved ) {
135 return $resolved_token;
136 }
137
138 $is_resolved = true;
139 $mcp_settings = get_option( 'zip_mcp_settings', array() );
140
141 if ( ! is_array( $mcp_settings ) ) {
142 $resolved_token = '';
143 return $resolved_token;
144 }
145
146 $auth_token = ! empty( $mcp_settings['auth_token'] ) && is_string( $mcp_settings['auth_token'] ) ? Utils::decrypt( $mcp_settings['auth_token'] ) : '';
147 $zip_token = ! empty( $mcp_settings['zip_token'] ) && is_string( $mcp_settings['zip_token'] ) ? Utils::decrypt( $mcp_settings['zip_token'] ) : '';
148 $email = ! empty( $mcp_settings['user_email'] ) ? sanitize_email( $mcp_settings['user_email'] ) : '';
149 $name = ! empty( $mcp_settings['user_name'] ) ? sanitize_text_field( $mcp_settings['user_name'] ) : '';
150 $current_api = self::get_credit_server_identifier();
151 $stored_api = ! empty( $mcp_settings['auth_token_server'] ) ? untrailingslashit( (string) $mcp_settings['auth_token_server'] ) : '';
152 $is_valid_auth = null;
153
154 if ( '' !== $auth_token && $stored_api === $current_api ) {
155 $resolved_token = $auth_token;
156 return $resolved_token;
157 }
158
159 if ( '' !== $auth_token && '' === $stored_api ) {
160 $is_valid_auth = self::validate_credit_server_auth_token( $auth_token );
161
162 if ( true === $is_valid_auth ) {
163 $mcp_settings['auth_token_server'] = $current_api;
164 update_option( 'zip_mcp_settings', $mcp_settings );
165 $resolved_token = $auth_token;
166 return $resolved_token;
167 }
168 }
169
170 if ( '' !== $zip_token && '' !== $email ) {
171 $exchange_result = self::exchange_zipwp_token_for_local_auth_token( $zip_token, $email, $name );
172
173 if ( ! empty( $exchange_result['token'] ) ) {
174 $mcp_settings['auth_token'] = Utils::encrypt( $exchange_result['token'] );
175 $mcp_settings['auth_token_server'] = $current_api;
176
177 if ( ! empty( $exchange_result['email'] ) ) {
178 $mcp_settings['user_email'] = sanitize_email( $exchange_result['email'] );
179 }
180
181 if ( ! empty( $exchange_result['name'] ) ) {
182 $mcp_settings['user_name'] = sanitize_text_field( $exchange_result['name'] );
183 }
184
185 update_option( 'zip_mcp_settings', $mcp_settings );
186 $resolved_token = $exchange_result['token'];
187 return $resolved_token;
188 }
189 }
190
191 if ( '' !== $auth_token && null === $is_valid_auth && '' === $stored_api ) {
192 $resolved_token = $auth_token;
193 return $resolved_token;
194 }
195
196 $resolved_token = '';
197 return $resolved_token;
198 }
199
200 /**
201 * Get the decrypted Application Password Authorization header value.
202 *
203 * Returns the pre-built `Basic <b64(user:password)>` string ready to be
204 * sent as the `Authorization` header (or forwarded to MCP via the
205 * `X-Wp-Authorization` custom header). Empty when no App Password is
206 * provisioned for the current connection — caller should treat that as
207 * "MCP tools unavailable" and surface to the admin.
208 *
209 * @since 1.0.0
210 * @return string The full `Basic …` Authorization header value, or empty string.
211 */
212 public static function get_decrypted_app_password_authorization() {
213 $mcp_settings = get_option( 'zip_mcp_settings', array() );
214
215 if ( ! empty( $mcp_settings['app_password_authorization'] ) && is_string( $mcp_settings['app_password_authorization'] ) ) {
216 return (string) Utils::decrypt( $mcp_settings['app_password_authorization'] );
217 }
218
219 return '';
220 }
221
222 /**
223 * Mint a WordPress Application Password for the current admin user and
224 * stash the pre-built Authorization header value (`Basic <b64>`) plus
225 * the App Password UUID into `zip_mcp_settings` (encrypted).
226 *
227 * Idempotent: when a stored UUID still resolves to a live App Password
228 * on the user record, this is a no-op — we trust the existing token.
229 * Stale UUIDs (deleted in Profile → Application Passwords) trigger a
230 * fresh mint.
231 *
232 * Called from the OAuth callback in `AJAX_Handlers::verify_authorization`
233 * right after the Sanctum `auth_token` is persisted. Piggy-backing on
234 * the existing connection click keeps the admin UX to one "Connect"
235 * step — the App Password is provisioned silently in the same flow.
236 *
237 * App Password plaintext is one-time-visible: WordPress hashes it and
238 * never exposes the plaintext again. We capture it in this single call,
239 * pre-build the Authorization header value once (`'Basic '. base64(...)`),
240 * encrypt the full value, and store. Plaintext is never persisted bare;
241 * it lives only inside the `Basic …` string, which is the form we need
242 * on the wire anyway.
243 *
244 * @since 1.0.0
245 * @return array{success: bool, code?: string, message?: string} status envelope.
246 */
247 public static function ensure_app_password_provisioned() {
248 $user_id = get_current_user_id();
249 if ( $user_id <= 0 ) {
250 return array(
251 'success' => false,
252 'code' => 'no_current_user',
253 'message' => __( 'No current user context — cannot provision Application Password.', 'zip-ai' ),
254 );
255 }
256
257 // Capability gate. Match the OAuth callback's own check so the App
258 // Password is only ever minted under an admin identity. The resulting
259 // token inherits the user's caps; we don't want a non-admin
260 // connection to silently mint a low-privilege token that then 401s
261 // every tool call.
262 if ( ! user_can( $user_id, 'manage_options' ) ) {
263 return array(
264 'success' => false,
265 'code' => 'insufficient_capability',
266 'message' => __( 'Connecting user lacks manage_options — Application Password not provisioned.', 'zip-ai' ),
267 );
268 }
269
270 if ( ! function_exists( 'wp_is_application_passwords_available' ) || ! wp_is_application_passwords_available() ) {
271 return array(
272 'success' => false,
273 'code' => 'app_passwords_disabled',
274 'message' => __( 'Application Passwords are disabled on this site. Enable them or contact your administrator.', 'zip-ai' ),
275 );
276 }
277
278 $user = get_user_by( 'id', $user_id );
279 if ( ! $user || ! wp_is_application_passwords_available_for_user( $user ) ) {
280 return array(
281 'success' => false,
282 'code' => 'app_passwords_disabled_for_user',
283 'message' => __( 'Application Passwords are disabled for the connecting user.', 'zip-ai' ),
284 );
285 }
286
287 // Idempotency: if we already minted one and it still exists on the
288 // user record, leave it alone. Re-minting on every reconnect would
289 // litter the user's Profile → Application Passwords screen.
290 $existing_uuid_encrypted = self::get_setting( 'app_password_uuid', '' );
291 $existing_uuid = is_string( $existing_uuid_encrypted ) && '' !== $existing_uuid_encrypted
292 ? (string) Utils::decrypt( $existing_uuid_encrypted )
293 : '';
294 if ( '' !== $existing_uuid ) {
295 $existing_record = \WP_Application_Passwords::get_user_application_password( $user_id, $existing_uuid );
296 if ( null !== $existing_record ) {
297 // Even though we already have the App Password locally, a
298 // reconnect typically issues a NEW Sanctum token on the
299 // SaaS side — and our credential is bound to that token's
300 // meta. Re-push the stored header so the new token also
301 // has it bound; the SaaS endpoint is idempotent.
302 $stored_header = self::get_decrypted_app_password_authorization();
303 if ( '' !== $stored_header ) {
304 self::push_app_password_to_saas( $stored_header );
305 }
306 return array(
307 'success' => true,
308 'code' => 'already_provisioned',
309 );
310 }
311 // Stale UUID — fall through and mint fresh. The next
312 // `update_setting` call overwrites the stored ciphertext.
313 }
314
315 $result = \WP_Application_Passwords::create_new_application_password(
316 $user_id,
317 array(
318 'name' => 'ZipWP MCP Connection',
319 'app_id' => 'zip-ai-' . wp_generate_uuid4(),
320 )
321 );
322
323 if ( is_wp_error( $result ) ) {
324 return array(
325 'success' => false,
326 'code' => 'create_failed',
327 'message' => $result->get_error_message(),
328 );
329 }
330
331 // `create_new_application_password` returns [ $plaintext_password, $item ].
332 // $item carries the persisted record metadata including `uuid`.
333 list( $plaintext, $item ) = $result;
334 if ( ! is_string( $plaintext ) || '' === $plaintext || ! is_array( $item ) || empty( $item['uuid'] ) ) {
335 return array(
336 'success' => false,
337 'code' => 'create_unexpected_shape',
338 'message' => __( 'WP_Application_Passwords returned an unexpected response shape.', 'zip-ai' ),
339 );
340 }
341
342 // Pre-build the full `Basic <b64(user_login:plaintext)>` header value
343 // — that's the form we actually need on the wire. Storing the
344 // pre-built string means plaintext never round-trips through the
345 // codebase at any point after this call.
346 $authorization_header = 'Basic ' . base64_encode( $user->user_login . ':' . $plaintext );
347
348 self::update_setting( 'app_password_uuid', (string) $item['uuid'] );
349 self::update_setting( 'app_password_authorization', $authorization_header );
350 self::update_setting( 'app_password_user_id', (string) $user_id );
351
352 // Server-to-server delivery — push the pre-built `Basic <b64>` value
353 // to the SaaS so the brain can pick it up from its DB at turn time.
354 // The credential never has to ride along on a client request header,
355 // which means it never lands in the iframe's inline JS where any
356 // other page script could read it. Soft-fail: if the push errors we
357 // still report `success: provisioned` locally so the admin sees the
358 // connection as established — the next chat turn will surface a
359 // clear MCP-auth error and the admin can disconnect/reconnect to
360 // trigger another bind attempt.
361 self::push_app_password_to_saas( $authorization_header );
362
363 return array(
364 'success' => true,
365 'code' => 'provisioned',
366 );
367 }
368
369 /**
370 * Bind the pre-built `Basic <b64>` Authorization header to the active
371 * Sanctum token on the SaaS via `POST /api/wp-credentials/bind`. Called
372 * right after a fresh App Password is minted (or proactively from the
373 * OAuth callback when an `already_provisioned` credential exists and
374 * needs to be re-bound after a SaaS-side wipe).
375 *
376 * Idempotent on both sides — the SaaS overwrites whatever value was
377 * previously stored under the same token's meta column.
378 *
379 * @since 1.0.0
380 * @param string $authorization_header Pre-built `Basic <b64(user:apppwd)>` value.
381 * @return bool True on HTTP 200, false on any error path.
382 */
383 public static function push_app_password_to_saas( $authorization_header ) {
384 if ( ! is_string( $authorization_header ) || '' === $authorization_header ) {
385 return false;
386 }
387
388 $auth_token = self::get_decrypted_auth_token();
389 if ( '' === $auth_token ) {
390 // No Sanctum token yet — the bind has to happen post-OAuth.
391 return false;
392 }
393
394 $response = wp_remote_post(
395 ZIPAI_MCP_CREDIT_SERVER_API . 'wp-credentials/bind',
396 array(
397 'headers' => array(
398 'Content-Type' => 'application/json',
399 'Accept' => 'application/json',
400 'Authorization' => 'Bearer ' . $auth_token,
401 ),
402 'body' => wp_json_encode(
403 array(
404 'authorization_header' => $authorization_header,
405 )
406 ),
407 'timeout' => 15,
408 'sslverify' => self::should_verify_ssl(),
409 )
410 );
411
412 if ( is_wp_error( $response ) ) {
413 error_log( '[zip-ai] wp-credentials/bind failed: ' . $response->get_error_message() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
414 return false;
415 }
416
417 $status_code = wp_remote_retrieve_response_code( $response );
418 if ( 200 !== (int) $status_code ) {
419 error_log( sprintf( '[zip-ai] wp-credentials/bind returned HTTP %d', (int) $status_code ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
420 return false;
421 }
422
423 return true;
424 }
425
426 /**
427 * Inverse of `push_app_password_to_saas()` — tell the SaaS to drop the
428 * bound credential. Called from `revoke_app_password()` so the SaaS
429 * state tracks the WP-side revoke.
430 *
431 * @since 1.0.0
432 * @return bool True on HTTP 200, false on any error path.
433 */
434 public static function unpush_app_password_from_saas() {
435 $auth_token = self::get_decrypted_auth_token();
436 if ( '' === $auth_token ) {
437 return false;
438 }
439
440 $response = wp_remote_post(
441 ZIPAI_MCP_CREDIT_SERVER_API . 'wp-credentials/unbind',
442 array(
443 'headers' => array(
444 'Content-Type' => 'application/json',
445 'Accept' => 'application/json',
446 'Authorization' => 'Bearer ' . $auth_token,
447 ),
448 'body' => wp_json_encode( array() ),
449 'timeout' => 15,
450 'sslverify' => self::should_verify_ssl(),
451 )
452 );
453
454 if ( is_wp_error( $response ) ) {
455 error_log( '[zip-ai] wp-credentials/unbind failed: ' . $response->get_error_message() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
456 return false;
457 }
458
459 $status_code = wp_remote_retrieve_response_code( $response );
460 return 200 === (int) $status_code;
461 }
462
463 /**
464 * Revoke the stored Application Password (if any) and clear its
465 * settings rows. Called from the disconnect AJAX handler.
466 *
467 * @since 1.0.0
468 * @return bool True if a password was revoked, false if nothing was stored.
469 */
470 public static function revoke_app_password() {
471 $mcp_settings = get_option( 'zip_mcp_settings', array() );
472 if ( ! is_array( $mcp_settings ) ) {
473 return false;
474 }
475
476 $uuid = ! empty( $mcp_settings['app_password_uuid'] ) && is_string( $mcp_settings['app_password_uuid'] )
477 ? (string) Utils::decrypt( $mcp_settings['app_password_uuid'] )
478 : '';
479 $user_id = ! empty( $mcp_settings['app_password_user_id'] ) && is_string( $mcp_settings['app_password_user_id'] )
480 ? (int) Utils::decrypt( $mcp_settings['app_password_user_id'] )
481 : 0;
482
483 $revoked = false;
484 if ( '' !== $uuid && $user_id > 0 && class_exists( '\WP_Application_Passwords' ) ) {
485 $revoked = (bool) \WP_Application_Passwords::delete_application_password( $user_id, $uuid );
486 }
487
488 // Drop the SaaS-side mirror BEFORE we wipe local meta so the bind
489 // endpoint still has access to the active Sanctum token (cleared
490 // separately by `zipwp_clear_settings()`). Soft-fail — the local
491 // revoke is authoritative; SaaS will 401 next turn if the
492 // unbind didn't land and the admin can reconnect.
493 self::unpush_app_password_from_saas();
494
495 unset(
496 $mcp_settings['app_password_authorization'],
497 $mcp_settings['app_password_uuid'],
498 $mcp_settings['app_password_user_id']
499 );
500 update_option( 'zip_mcp_settings', $mcp_settings );
501
502 return $revoked;
503 }
504
505 /**
506 * Validate the auth token against the configured credit server.
507 *
508 * @param string $auth_token The auth token to validate.
509 * @since 1.0.0
510 * @return bool|null True when valid, false when rejected, null when validation could not be completed.
511 */
512 public static function validate_credit_server_auth_token( $auth_token ) {
513 if ( empty( $auth_token ) || ! is_string( $auth_token ) ) {
514 return false;
515 }
516
517 $response = wp_remote_post(
518 ZIPAI_MCP_CREDIT_SERVER_API . 'auth/validate',
519 array(
520 'headers' => array(
521 'Content-Type' => 'application/json',
522 'Accept' => 'application/json',
523 'Authorization' => 'Bearer ' . $auth_token,
524 ),
525 'body' => wp_json_encode( array() ),
526 'timeout' => 15,
527 'sslverify' => self::should_verify_ssl(),
528 )
529 );
530
531 if ( is_wp_error( $response ) ) {
532 return null;
533 }
534
535 $status_code = wp_remote_retrieve_response_code( $response );
536 $response_body = wp_remote_retrieve_body( $response );
537 $response_data = json_decode( $response_body, true );
538
539 if ( 200 === $status_code ) {
540 return ! empty( $response_data['valid'] );
541 }
542
543 if ( 401 === $status_code || 403 === $status_code ) {
544 return false;
545 }
546
547 return null;
548 }
549
550 /**
551 * Exchange a ZipWP app token for a local credit-server auth token.
552 *
553 * @param string $zip_token The ZipWP app token.
554 * @param string $email The user email.
555 * @param string $name The user name.
556 * @since 1.0.0
557 * @return array The exchange result containing token details, or an empty array on failure.
558 */
559 public static function exchange_zipwp_token_for_local_auth_token( $zip_token, $email, $name = '' ) {
560 if ( empty( $zip_token ) || ! is_string( $zip_token ) || empty( $email ) || ! is_string( $email ) ) {
561 return array();
562 }
563
564 $request_body = array(
565 'token' => $zip_token,
566 'email' => $email,
567 'site_id' => self::get_site_id(),
568 );
569
570 if ( '' !== $name ) {
571 $request_body['name'] = $name;
572 }
573
574 if ( get_current_user_id() > 0 ) {
575 $request_body['user_id'] = get_current_user_id();
576 }
577
578 $response = wp_remote_post(
579 ZIPAI_MCP_CREDIT_SERVER_API . 'token/exchange',
580 array(
581 'headers' => array(
582 'Content-Type' => 'application/json',
583 'Accept' => 'application/json',
584 ),
585 'body' => wp_json_encode( $request_body ),
586 'timeout' => 15,
587 'sslverify' => self::should_verify_ssl(),
588 )
589 );
590
591 if ( is_wp_error( $response ) ) {
592 return array();
593 }
594
595 $status_code = wp_remote_retrieve_response_code( $response );
596 $response_body = wp_remote_retrieve_body( $response );
597 $response_data = json_decode( $response_body, true );
598
599 if ( ! in_array( $status_code, array( 200, 201 ), true ) || empty( $response_data['success'] ) || empty( $response_data['token'] ) ) {
600 return array();
601 }
602
603 return array(
604 'token' => sanitize_text_field( $response_data['token'] ),
605 'email' => ! empty( $response_data['email'] ) ? sanitize_email( $response_data['email'] ) : $email,
606 'name' => ! empty( $response_data['name'] ) ? sanitize_text_field( $response_data['name'] ) : $name,
607 );
608 }
609
610 /**
611 * Get the configured credit server identifier for token compatibility checks.
612 *
613 * @since 1.0.0
614 * @return string The normalized credit server API URL.
615 */
616 private static function get_credit_server_identifier() {
617 return untrailingslashit( ZIPAI_MCP_CREDIT_SERVER_API );
618 }
619
620 /**
621 * Generate a shared secret for HMAC authentication.
622 *
623 * @since 1.0.0
624 * @return string The generated shared secret.
625 */
626 public static function generate_shared_secret() {
627 return bin2hex( random_bytes( 32 ) );
628 }
629 /**
630 * Get or generate the shared secret for HMAC authentication.
631 *
632 * @since 1.0.0
633 * @return string The shared secret.
634 */
635 public static function get_shared_secret() {
636 // Get the encrypted shared secret from settings.
637 $encrypted_shared_secret = self::get_setting( 'shared_secret', '' );
638
639 if ( empty( $encrypted_shared_secret ) ) {
640 // Generate new shared secret.
641 $shared_secret = self::generate_shared_secret();
642 self::update_setting( 'shared_secret', $shared_secret );
643 return $shared_secret;
644 }
645
646 // Decrypt and return the existing shared secret.
647 return Utils::decrypt( $encrypted_shared_secret );
648 }
649
650 /**
651 * Update a specific setting in the ZIP AI settings.
652 *
653 * @param string $key The setting key.
654 * @param mixed $value The setting value.
655 * @since 1.0.0
656 * @return bool True if the setting was updated, false otherwise.
657 */
658 public static function update_setting( $key, $value ) {
659 $existing_settings = self::get_admin_settings_option( 'zip_mcp_settings', array() );
660
661 if ( ! is_array( $existing_settings ) ) {
662 $existing_settings = array();
663 }
664
665 $existing_settings[ $key ] = Utils::encrypt( $value );
666
667 return self::update_admin_settings_option( 'zip_mcp_settings', $existing_settings );
668 }
669
670 /**
671 * Get the site ID for HMAC authentication.
672 *
673 * @since 1.0.0
674 * @return string The site ID.
675 */
676 public static function get_site_id() {
677 return get_site_url();
678 }
679
680 /**
681 * Register the shared secret with Laravel server during plugin activation.
682 *
683 * @since 1.0.0
684 * @return array The registration response.
685 */
686 public static function register_shared_secret_with_laravel() {
687 $shared_secret = self::get_shared_secret();
688 $site_id = self::get_site_id();
689
690 // Register endpoint - this should point to your Laravel server.
691 $register_endpoint = ZIPAI_MCP_CREDIT_SERVER_API . 'auth/register-secret';
692
693 $registration_data = array(
694 'site_id' => $site_id,
695 'secret' => $shared_secret,
696 'site_name' => get_bloginfo( 'name' ),
697 'admin_email' => get_option( 'admin_email' ),
698 );
699
700 $response = wp_remote_post(
701 $register_endpoint,
702 array(
703 'headers' => array(
704 'Content-Type' => 'application/json',
705 ),
706 'body' => wp_json_encode( $registration_data ),
707 'timeout' => 30,
708 )
709 );
710
711 if ( is_wp_error( $response ) ) {
712 return array(
713 'error' => $response->get_error_message(),
714 'code' => 'registration_failed',
715 );
716 }
717
718 $response_body = wp_remote_retrieve_body( $response );
719 $status_code = wp_remote_retrieve_response_code( $response );
720
721 if ( 200 !== $status_code ) {
722 return array(
723 'error' => __( 'Failed to register with Laravel server.', 'zip-ai' ),
724 'code' => 'registration_failed',
725 );
726 }
727
728 $response_data = json_decode( $response_body, true );
729
730 // Store the registration status.
731 self::update_setting( 'hmac_registered', 'true' );
732
733 return $response_data;
734 }
735
736 /**
737 * Check if HMAC is registered with Laravel.
738 *
739 * @since 1.0.0
740 * @return bool True if registered, false otherwise.
741 */
742 public static function is_hmac_registered() {
743 return 'true' === self::get_setting( 'hmac_registered', 'false' );
744 }
745
746 /**
747 * Prepare a block array for WordPress serialize_blocks().
748 *
749 * LLM-generated blocks only have blockName/attrs/innerBlocks.
750 * WordPress serialize_block() requires innerHTML and innerContent
751 * to know how to render the block tree. This adds the missing keys.
752 *
753 * - Blocks with innerBlocks: innerContent = [null, null, ...] (one per child)
754 * - Blocks without innerBlocks: innerContent = [] (self-closing)
755 * - Already-prepared blocks (from parse_blocks): left untouched
756 *
757 * @since 1.0.0
758 * @param array $blocks Array of block objects.
759 * @return array Blocks ready for serialize_blocks().
760 */
761 public static function prepare_blocks_for_serialization( $blocks ) {
762 if ( ! is_array( $blocks ) ) {
763 return array();
764 }
765
766 return array_map( function( $block ) {
767 if ( ! is_array( $block ) ) {
768 return $block;
769 }
770
771 // Already prepared (e.g. from parse_blocks) — skip
772 if ( isset( $block['innerContent'] ) ) {
773 // Still recurse into innerBlocks in case they need preparation
774 if ( ! empty( $block['innerBlocks'] ) ) {
775 $block['innerBlocks'] = Helper::prepare_blocks_for_serialization( $block['innerBlocks'] );
776 }
777 return $block;
778 }
779
780 // Ensure attrs is an array
781 if ( ! isset( $block['attrs'] ) || ! is_array( $block['attrs'] ) ) {
782 $block['attrs'] = array();
783 }
784
785 // Recursively prepare innerBlocks first
786 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
787 $block['innerBlocks'] = Helper::prepare_blocks_for_serialization( $block['innerBlocks'] );
788 // One null per inner block — tells serialize_block() where to place each child
789 $block['innerContent'] = array_fill( 0, count( $block['innerBlocks'] ), null );
790 } else {
791 $block['innerBlocks'] = array();
792 $block['innerContent'] = array();
793 }
794
795 // innerHTML is empty for Spectra blocks (content is in attrs + innerBlocks)
796 if ( ! isset( $block['innerHTML'] ) ) {
797 $block['innerHTML'] = '';
798 }
799
800 return $block;
801 }, $blocks );
802 }
803
804 }
805