PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.6
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.6
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.6, at classes/core/helper.php

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