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 / core / helper.php

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

714 lines 21.2 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\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\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 ZIP_AI_DISABLE_SSL_VERIFY
31 * constant or the 'zip_ai_sslverify' filter.
32 *
33 * @since 0.0.1
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( 'ZIP_AI_DISABLE_SSL_VERIFY' ) && ZIP_AI_DISABLE_SSL_VERIFY ) {
42 $verify_ssl = false;
43 }
44
45 /**
46 * Filter whether SSL verification should be enabled for remote requests.
47 *
48 * @since 0.0.1
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 0.0.1
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 0.0.1
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 * Delete an option from the database for.
84 *
85 * @param string $key The option key.
86 * @param boolean $network_override Whether to allow the network admin setting to be overridden on subsites.
87 * @since 0.0.1
88 * @return void
89 */
90 public static function delete_admin_settings_option( $key, $network_override = false ) {
91 // Delete the site-wide option if we're in the network admin.
92 if ( $network_override && is_multisite() ) {
93 delete_site_option( $key );
94 } else {
95 delete_option( $key );
96 }
97 }
98
99 /**
100 * Check if Zip AI is authorized.
101 *
102 * @since 0.0.1
103 * @return boolean True if Zip AI is authorized, false otherwise.
104 */
105 public static function is_authorized() {
106 // Check zip_ai_settings for auth_token.
107 $auth_token = self::get_decrypted_auth_token();
108
109 return ! empty( $auth_token ) && is_string( $auth_token ) && ! empty( trim( $auth_token ) );
110 }
111
112 /**
113 * Get the Zip AI Settings.
114 *
115 * If used with a key, it will return that specific setting.
116 * If used without a key, it will return the entire settings array.
117 *
118 * @param string $key The setting key.
119 * @param mixed $default The default value to return if the setting is not found.
120 * @since 0.0.1
121 * @return mixed|array The setting value, or the default.
122 */
123 public static function get_setting( $key = '', $default = array() ) {
124
125 // Get the Zip AI settings.
126 $existing_settings = self::get_admin_settings_option( 'zip_ai_settings' );
127
128 // If the Zip AI settings are empty, return the fallback.
129 if ( empty( $existing_settings ) || ! is_array( $existing_settings ) ) {
130 return $default;
131 }
132
133 // If the key is empty, return the entire settings array - otherwise return the specific setting or the fallback.
134 if ( empty( $key ) ) {
135 return $existing_settings;
136 } else {
137 return isset( $existing_settings[ $key ] ) ? $existing_settings[ $key ] : $default;
138 }
139 }
140
141 /**
142 * Get the decrypted auth token from zip_ai_settings.
143 *
144 * @since 0.0.1
145 * @return string The decrypted auth token.
146 */
147 public static function get_decrypted_auth_token() {
148 static $resolved_token = null;
149 static $is_resolved = false;
150
151 if ( $is_resolved ) {
152 return $resolved_token;
153 }
154
155 $is_resolved = true;
156 $mcp_settings = get_option( 'zip_ai_settings', array() );
157
158 if ( ! is_array( $mcp_settings ) ) {
159 $resolved_token = '';
160 return $resolved_token;
161 }
162
163 $auth_token = ! empty( $mcp_settings['auth_token'] ) && is_string( $mcp_settings['auth_token'] ) ? Utils::decrypt( $mcp_settings['auth_token'] ) : '';
164 $zip_token = ! empty( $mcp_settings['zip_token'] ) && is_string( $mcp_settings['zip_token'] ) ? Utils::decrypt( $mcp_settings['zip_token'] ) : '';
165 $email = ! empty( $mcp_settings['user_email'] ) ? sanitize_email( $mcp_settings['user_email'] ) : '';
166 $name = ! empty( $mcp_settings['user_name'] ) ? sanitize_text_field( $mcp_settings['user_name'] ) : '';
167 $current_api = self::get_credit_server_identifier();
168 $stored_api = ! empty( $mcp_settings['auth_token_server'] ) ? untrailingslashit( (string) $mcp_settings['auth_token_server'] ) : '';
169 $is_valid_auth = null;
170
171 if ( '' !== $auth_token && $stored_api === $current_api ) {
172 $resolved_token = $auth_token;
173 return $resolved_token;
174 }
175
176 if ( '' !== $auth_token && '' === $stored_api ) {
177 $is_valid_auth = self::validate_credit_server_auth_token( $auth_token );
178
179 if ( true === $is_valid_auth ) {
180 $mcp_settings['auth_token_server'] = $current_api;
181 update_option( 'zip_ai_settings', $mcp_settings, false );
182 $resolved_token = $auth_token;
183 return $resolved_token;
184 }
185 }
186
187 if ( '' !== $zip_token && '' !== $email ) {
188 $exchange_result = self::exchange_zip_ai_token_for_local_auth_token( $zip_token, $email, $name );
189
190 if ( ! empty( $exchange_result['token'] ) ) {
191 $mcp_settings['auth_token'] = Utils::encrypt( $exchange_result['token'] );
192 $mcp_settings['auth_token_server'] = $current_api;
193
194 if ( ! empty( $exchange_result['email'] ) ) {
195 $mcp_settings['user_email'] = sanitize_email( $exchange_result['email'] );
196 }
197
198 if ( ! empty( $exchange_result['name'] ) ) {
199 $mcp_settings['user_name'] = sanitize_text_field( $exchange_result['name'] );
200 }
201
202 update_option( 'zip_ai_settings', $mcp_settings, false );
203 $resolved_token = $exchange_result['token'];
204 return $resolved_token;
205 }
206 }
207
208 if ( '' !== $auth_token && null === $is_valid_auth && '' === $stored_api ) {
209 $resolved_token = $auth_token;
210 return $resolved_token;
211 }
212
213 $resolved_token = '';
214 return $resolved_token;
215 }
216
217 /**
218 * Validate the auth token against the configured credit server.
219 *
220 * @param string $auth_token The auth token to validate.
221 * @since 0.0.1
222 * @return bool|null True when valid, false when rejected, null when validation could not be completed.
223 */
224 public static function validate_credit_server_auth_token( $auth_token ) {
225 if ( empty( $auth_token ) || ! is_string( $auth_token ) ) {
226 return false;
227 }
228
229 $response = wp_remote_post(
230 ZIP_AI_CREDIT_SERVER_API . 'auth/validate',
231 array(
232 'headers' => array(
233 'Content-Type' => 'application/json',
234 'Accept' => 'application/json',
235 'Authorization' => 'Bearer ' . $auth_token,
236 ),
237 'body' => wp_json_encode( array() ),
238 'timeout' => 15,
239 'sslverify' => self::should_verify_ssl(),
240 )
241 );
242
243 if ( is_wp_error( $response ) ) {
244 return null;
245 }
246
247 $status_code = wp_remote_retrieve_response_code( $response );
248 $response_body = wp_remote_retrieve_body( $response );
249 $response_data = json_decode( $response_body, true );
250
251 if ( 200 === $status_code ) {
252 return ! empty( $response_data['valid'] );
253 }
254
255 if ( 401 === $status_code || 403 === $status_code ) {
256 return false;
257 }
258
259 return null;
260 }
261
262 /**
263 * Exchange a ZipWP app token for a local credit-server auth token.
264 *
265 * @param string $zip_token The ZipWP app token.
266 * @param string $email The user email.
267 * @param string $name The user name.
268 * @since 0.0.1
269 * @return array The exchange result containing token details, or an empty array on failure.
270 */
271 public static function exchange_zip_ai_token_for_local_auth_token( $zip_token, $email, $name = '' ) {
272 if ( empty( $zip_token ) || ! is_string( $zip_token ) || empty( $email ) || ! is_string( $email ) ) {
273 return array();
274 }
275
276 $request_body = array(
277 'token' => $zip_token,
278 'email' => $email,
279 'site_id' => self::get_site_id(),
280 );
281
282 if ( '' !== $name ) {
283 $request_body['name'] = $name;
284 }
285
286 if ( get_current_user_id() > 0 ) {
287 $request_body['user_id'] = get_current_user_id();
288 }
289
290 $response = wp_remote_post(
291 ZIP_AI_CREDIT_SERVER_API . 'token/exchange',
292 array(
293 'headers' => array(
294 'Content-Type' => 'application/json',
295 'Accept' => 'application/json',
296 ),
297 'body' => wp_json_encode( $request_body ),
298 'timeout' => 15,
299 'sslverify' => self::should_verify_ssl(),
300 )
301 );
302
303 if ( is_wp_error( $response ) ) {
304 return array();
305 }
306
307 $status_code = wp_remote_retrieve_response_code( $response );
308 $response_body = wp_remote_retrieve_body( $response );
309 $response_data = json_decode( $response_body, true );
310
311 if ( ! in_array( $status_code, array( 200, 201 ), true ) || empty( $response_data['success'] ) || empty( $response_data['token'] ) ) {
312 return array();
313 }
314
315 return array(
316 'token' => sanitize_text_field( $response_data['token'] ),
317 'email' => ! empty( $response_data['email'] ) ? sanitize_email( $response_data['email'] ) : $email,
318 'name' => ! empty( $response_data['name'] ) ? sanitize_text_field( $response_data['name'] ) : $name,
319 );
320 }
321
322 /**
323 * Get the configured credit server identifier for token compatibility checks.
324 *
325 * @since 0.0.1
326 * @return string The normalized credit server API URL.
327 */
328 private static function get_credit_server_identifier() {
329 return untrailingslashit( ZIP_AI_CREDIT_SERVER_API );
330 }
331
332 /**
333 * Generate a shared secret for HMAC authentication.
334 *
335 * @since 0.0.1
336 * @return string The generated shared secret.
337 */
338 public static function generate_shared_secret() {
339 return bin2hex( random_bytes( 32 ) );
340 }
341 /**
342 * Get or generate the shared secret for HMAC authentication.
343 *
344 * @since 0.0.1
345 * @return string The shared secret.
346 */
347 public static function get_shared_secret() {
348 // Get the encrypted shared secret from settings.
349 $encrypted_shared_secret = self::get_setting( 'shared_secret', '' );
350
351 if ( empty( $encrypted_shared_secret ) ) {
352 // Generate new shared secret.
353 $shared_secret = self::generate_shared_secret();
354 self::update_setting( 'shared_secret', $shared_secret );
355 return $shared_secret;
356 }
357
358 // Decrypt and return the existing shared secret.
359 return Utils::decrypt( $encrypted_shared_secret );
360 }
361
362 /**
363 * Update a specific setting in the Zip AI settings.
364 *
365 * @param string $key The setting key.
366 * @param mixed $value The setting value.
367 * @since 0.0.1
368 * @return bool True if the setting was updated, false otherwise.
369 */
370 public static function update_setting( $key, $value ) {
371 $existing_settings = self::get_admin_settings_option( 'zip_ai_settings', array() );
372
373 if ( ! is_array( $existing_settings ) ) {
374 $existing_settings = array();
375 }
376
377 $existing_settings[ $key ] = Utils::encrypt( $value );
378
379 return self::update_admin_settings_option( 'zip_ai_settings', $existing_settings );
380 }
381
382 /**
383 * Generate HMAC signature for request.
384 *
385 * @param string $request_body The request body.
386 * @param string $timestamp The timestamp.
387 * @since 0.0.1
388 * @return string The HMAC signature.
389 */
390 public static function generate_hmac_signature( $request_body, $timestamp ) {
391 $shared_secret = self::get_shared_secret();
392 $data = $request_body . $timestamp;
393 return hash_hmac( 'sha256', $data, $shared_secret );
394 }
395
396 /**
397 * Get the site ID for HMAC authentication.
398 *
399 * @since 0.0.1
400 * @return string The site ID.
401 */
402 public static function get_site_id() {
403 return get_site_url();
404 }
405
406 /**
407 * Register the shared secret with Laravel server during plugin activation.
408 *
409 * @since 0.0.1
410 * @return array The registration response.
411 */
412 public static function register_shared_secret_with_laravel() {
413 $shared_secret = self::get_shared_secret();
414 $site_id = self::get_site_id();
415
416 // Register endpoint - this should point to your Laravel server.
417 $register_endpoint = ZIP_AI_CREDIT_SERVER_API . 'auth/register-secret';
418
419 $registration_data = array(
420 'site_id' => $site_id,
421 'secret' => $shared_secret,
422 'site_name' => get_bloginfo( 'name' ),
423 'admin_email' => get_option( 'admin_email' ),
424 );
425
426 $response = wp_remote_post(
427 $register_endpoint,
428 array(
429 'headers' => array(
430 'Content-Type' => 'application/json',
431 ),
432 'body' => wp_json_encode( $registration_data ),
433 'timeout' => 30,
434 )
435 );
436
437 if ( is_wp_error( $response ) ) {
438 return array(
439 'error' => $response->get_error_message(),
440 'code' => 'registration_failed',
441 );
442 }
443
444 $response_body = wp_remote_retrieve_body( $response );
445 $status_code = wp_remote_retrieve_response_code( $response );
446
447 if ( 200 !== $status_code ) {
448 return array(
449 'error' => __( 'Failed to register with Laravel server.', 'zip-ai' ),
450 'code' => 'registration_failed',
451 );
452 }
453
454 $response_data = json_decode( $response_body, true );
455
456 // Store the registration status.
457 self::update_setting( 'hmac_registered', 'true' );
458
459 return $response_data;
460 }
461
462 /**
463 * Check if HMAC is registered with Laravel.
464 *
465 * @since 0.0.1
466 * @return bool True if registered, false otherwise.
467 */
468 public static function is_hmac_registered() {
469 return 'true' === self::get_setting( 'hmac_registered', 'false' );
470 }
471
472 /**
473 * Get a response from the ZipWP API server with HMAC authentication.
474 *
475 * @param string $endpoint The endpoint to get the response from.
476 * @param array $body The data to be passed as the request body, if any.
477 * @since 0.0.1
478 * @return array The ZipWP API Response.
479 */
480 public static function get_zip_ai_api_response_with_hmac( $endpoint, $body = array() ) {
481 // If the endpoint is not a string, then abandon ship.
482 if ( ! is_string( $endpoint ) ) {
483 return array(
484 'error' => __( 'The ZipWP Endpoint was not declared', 'zip-ai' ),
485 );
486 }
487
488 // Set the API URL.
489 $api_url = ZIP_AI_CREDIT_SERVER_API . $endpoint;
490
491 // Prepare request body for HMAC signing.
492 $request_body = '';
493 if ( ! empty( $body ) && is_array( $body ) ) {
494 $request_body = wp_json_encode( $body );
495 }
496
497 // Generate timestamp in ISO8601 format.
498 $timestamp = gmdate( 'c' );
499
500 // Generate HMAC signature.
501 $signature = self::generate_hmac_signature( $request_body, $timestamp );
502
503 // Prepare request arguments with HMAC headers.
504 $request_args = array(
505 'headers' => array(
506 'Content-Type' => 'application/json',
507 'Accept' => 'application/json',
508 'X-Site-Id' => self::get_site_id(),
509 'X-Timestamp' => $timestamp,
510 'X-Signature' => $signature,
511 'X-Plugin-Version' => ZIP_AI_VERSION,
512 ),
513 'timeout' => 30, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- 30 seconds is required sometime for the ZipWP API response
514 'sslverify' => self::should_verify_ssl(),
515 );
516
517 // Add request body if provided.
518 if ( ! empty( $request_body ) ) {
519 $request_args['body'] = $request_body;
520 $response = wp_remote_post( $api_url, $request_args );
521 } else {
522 $response = wp_remote_get( $api_url, $request_args );
523 }
524
525 // If the response was an error, or not a 200 status code, then abandon ship.
526 if ( is_wp_error( $response ) || empty( $response['response'] ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
527 return array(
528 'error' => __( 'The ZipWP API server is not responding.', 'zip-ai' ),
529 );
530 }
531
532 // Get the response body.
533 $response_body = wp_remote_retrieve_body( $response );
534
535 // If the response body is not a JSON, then abandon ship.
536 if ( empty( $response_body ) || ! json_decode( $response_body ) ) {
537 return array(
538 'error' => __( 'The ZipWP API server encountered an error.', 'zip-ai' ),
539 );
540 }
541
542 // Return the response body.
543 return json_decode( $response_body, true );
544 }
545
546 /**
547 * Check if the current user is allowed to use ZipWP MCP.
548 *
549 * @since 0.0.1
550 * @return bool True if user is allowed, false otherwise.
551 */
552 public static function is_user_allowed() {
553 return current_user_can( 'manage_zip_ai_assistant' );
554 }
555
556 /**
557 * Get all ZipWP MCP settings.
558 *
559 * @since 0.0.1
560 * @return array The settings array.
561 */
562 public static function get_settings() {
563 return self::get_setting();
564 }
565
566 /**
567 * Get the auth middleware URL with redirect.
568 * Matches UAG implementation exactly.
569 *
570 * @param array $params Optional parameters.
571 * @since 0.0.1
572 * @return string The auth middleware URL.
573 */
574 public static function get_auth_middleware_url( $params = array() ) {
575
576 // Create the Redirect URL.
577 $redirect_url = add_query_arg(
578 array(
579 'nonce' => wp_create_nonce( 'zip_ai_auth_nonce' ),
580 'scs-authorize' => 'true',
581 ),
582 admin_url()
583 );
584
585 // Create the Authentication URL.
586 $auth_url = add_query_arg(
587 array(
588 'type' => 'token',
589 'redirect_url' => rawurlencode( $redirect_url ),
590 ),
591 ZIP_AI_MIDDLEWARE
592 );
593
594 // Add the plugin param if passed.
595 if ( ! empty( $params['plugin'] ) && is_string( $params['plugin'] ) ) {
596 $auth_url = add_query_arg(
597 'plugin',
598 sanitize_text_field( $params['plugin'] ),
599 $auth_url
600 );
601 }
602
603 // Add the source param if passed.
604 if ( ! empty( $params['source'] ) && is_string( $params['source'] ) ) {
605 $auth_url = add_query_arg(
606 'source',
607 sanitize_text_field( $params['source'] ),
608 $auth_url
609 );
610 }
611
612 return $auth_url;
613 }
614
615 /**
616 * Prepare a block array for WordPress serialize_blocks().
617 *
618 * LLM-generated blocks only have blockName/attrs/innerBlocks.
619 * WordPress serialize_block() requires innerHTML and innerContent
620 * to know how to render the block tree. This adds the missing keys.
621 *
622 * - Blocks with innerBlocks: innerContent = [null, null, ...] (one per child)
623 * - Blocks without innerBlocks: innerContent = [] (self-closing)
624 * - Already-prepared blocks (from parse_blocks): left untouched
625 *
626 * @since 0.0.1
627 * @param array $blocks Array of block objects.
628 * @return array Blocks ready for serialize_blocks().
629 */
630 public static function prepare_blocks_for_serialization( $blocks ) {
631 if ( ! is_array( $blocks ) ) {
632 return array();
633 }
634
635 return array_map(
636 function ( $block ) {
637 if ( ! is_array( $block ) ) {
638 return $block;
639 }
640
641 // Already prepared (e.g. from parse_blocks) — skip
642 if ( isset( $block['innerContent'] ) ) {
643 // Still recurse into innerBlocks in case they need preparation
644 if ( ! empty( $block['innerBlocks'] ) ) {
645 $block['innerBlocks'] = Helper::prepare_blocks_for_serialization( $block['innerBlocks'] );
646 }
647 return $block;
648 }
649
650 // Ensure attrs is an array
651 if ( ! isset( $block['attrs'] ) || ! is_array( $block['attrs'] ) ) {
652 $block['attrs'] = array();
653 }
654
655 // Recursively prepare innerBlocks first
656 if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
657 $block['innerBlocks'] = Helper::prepare_blocks_for_serialization( $block['innerBlocks'] );
658 // One null per inner block — tells serialize_block() where to place each child
659 $block['innerContent'] = array_fill( 0, count( $block['innerBlocks'] ), null );
660 } else {
661 $block['innerBlocks'] = array();
662 $block['innerContent'] = array();
663 }
664
665 // innerHTML is empty for Spectra blocks (content is in attrs + innerBlocks)
666 if ( ! isset( $block['innerHTML'] ) ) {
667 $block['innerHTML'] = '';
668 }
669
670 return $block;
671 },
672 $blocks
673 );
674 }
675
676 /**
677 * Get installed plugins with their versions.
678 *
679 * Returns a map of plugin_slug => version for all installed plugins (active and inactive).
680 *
681 * @since 0.0.1
682 * @return array Map of plugin_slug => version.
683 */
684 public static function get_installed_plugin_versions() {
685 if ( ! function_exists( 'get_plugins' ) ) {
686 require_once ABSPATH . 'wp-admin/includes/plugin.php';
687 }
688
689 $all_plugins = get_plugins();
690 $result = array();
691
692 // Map of plugin directories to their canonical slugs (for known aliases).
693 $slug_aliases = array(
694 'ultimate-addons-for-gutenberg' => 'spectra',
695 );
696
697 foreach ( $all_plugins as $plugin_file => $plugin_data ) {
698 // Get the plugin directory (first part of plugin_file).
699 $plugin_dir = dirname( $plugin_file );
700 if ( '.' === $plugin_dir ) {
701 // Single file plugin, use filename without .php.
702 $plugin_dir = str_replace( '.php', '', $plugin_file );
703 }
704
705 // Use canonical slug if there's an alias, otherwise use directory name.
706 $slug = $slug_aliases[ $plugin_dir ] ?? $plugin_dir;
707
708 $result[ $slug ] = $plugin_data['Version'] ?? '0.0.0';
709 }
710
711 return $result;
712 }
713 }
714