PluginProbe
ZIP AI – AI Website Builder & AI Agent / 0.0.11
ZIP AI – AI Website Builder & AI Agent v0.0.11
0.0.13 0.0.12 0.0.11 0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / inc / api / ajax-handlers.php

ajax-handlers.php in ZIP AI – AI Website Builder & AI Agent 0.0.11, at inc/api/ajax-handlers.php

430 lines 16.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AJAX Handlers.
4 *
5 * @package zip-ai
6 */
7
8 namespace ZipAI\MCP\Classes\Api;
9
10 // Exit if accessed directly.
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13 }
14
15 // Classes to be used.
16 use ZipAI\MCP\Classes\Abilities\Zipai\System\PluginResolver;
17 use ZipAI\MCP\Classes\Core\Helper;
18 use ZipAI\MCP\Classes\Core\Plugin_Abilities_Toggler;
19 use ZipAI\MCP\Classes\Core\Utils;
20
21 /**
22 * The AJAX_Handlers Class.
23 * Handles all AJAX requests.
24 */
25 class AJAX_Handlers {
26
27 /**
28 * Constructor of this class.
29 *
30 * @since 1.0.0
31 * @return void
32 */
33 public function __construct() {
34 // Hook into admin_init to catch OAuth callback and sync to zip_mcp_settings.
35 add_action( 'admin_init', array( $this, 'verify_authorization' ), 5 );
36
37 // Authentication handlers.
38 add_action( 'wp_ajax_zipwp_clear_settings', array( $this, 'zipwp_clear_settings' ) );
39 add_action( 'wp_ajax_zipwp_verify_auth_status', array( $this, 'zipwp_verify_auth_status' ) );
40
41 // Media upload from base64.
42 add_action( 'wp_ajax_zip_ai_upload_media', array( $this, 'zip_ai_upload_media' ) );
43
44 // Dismiss the inline setup notice (per-user, persists across reloads).
45 add_action( 'wp_ajax_zip_ai_dismiss_setup_gate', array( $this, 'zip_ai_dismiss_setup_gate' ) );
46 // Activate the Spectra plugin / Spectra One theme for the setup gate.
47 add_action( 'wp_ajax_zip_ai_activate_setup_item', array( $this, 'zip_ai_activate_setup_item' ) );
48 }
49
50
51
52
53 /**
54 * Clear authentication settings (logout).
55 *
56 * @since 1.0.0
57 * @return void
58 */
59 public function zipwp_clear_settings() {
60 // Check nonce for security.
61 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Nonce is verified, not stored.
62 $nonce = isset( $_POST['nonce'] ) && is_string( $_POST['nonce'] ) ? $_POST['nonce'] : '';
63 if ( ! wp_verify_nonce( $nonce, 'zip_ai_iframe' ) ) {
64 wp_send_json_error( 'Security check failed' );
65 }
66
67 // `manage_options` — this handler revokes the App Password and
68 // wipes the connection. Should match every other auth/settings
69 // surface; an editor must not be able to disconnect the site.
70 if ( ! current_user_can( 'manage_options' ) ) {
71 wp_send_json_error( 'Insufficient permissions' );
72 }
73
74 // Revoke the stored WordPress Application Password (if any) so
75 // reconnecting later mints a fresh one and the user's Profile
76 // → Application Passwords screen reflects an accurate state.
77 // Runs before the option write below so the app_password_* keys
78 // referenced by `revoke_app_password()` are still in the option.
79 Helper::revoke_app_password();
80
81 // Clear authentication data from mcp settings.
82 $mcp_settings = get_option( 'zip_mcp_settings', array() );
83 if ( ! is_array( $mcp_settings ) ) {
84 $mcp_settings = array();
85 }
86 unset( $mcp_settings['auth_token'] );
87 unset( $mcp_settings['auth_token_server'] );
88 // The ZipWP app token is connection-scoped too. Its only reader is the
89 // re-exchange in Helper::get_decrypted_auth_token(), which also requires
90 // `user_email` — cleared below — so leaving it was inert rather than
91 // harmful. Unset for consistency: a disconnected site should hold no
92 // credential from the connection it just dropped.
93 unset( $mcp_settings['zip_token'] );
94 unset( $mcp_settings['user_id'] );
95 unset( $mcp_settings['user_email'] );
96 unset( $mcp_settings['user_name'] );
97 unset( $mcp_settings['site_id'] );
98 unset( $mcp_settings['domain'] );
99 unset( $mcp_settings['authenticated_at'] );
100 // The team belongs to the connection, not to the site. Left in place it
101 // outlives the disconnect, and a reconnect whose callback carries no
102 // team_uuid leaves the stale one to be read back by
103 // Helper::get_decrypted_auth_token() and sent on a later re-exchange —
104 // re-pointing the credit server at a team the user may have left.
105 unset( $mcp_settings['team_uuid'] );
106 // App Password fields are also unset by `revoke_app_password()`
107 // above, but we re-apply here defensively in case the revoke
108 // path returned early due to a missing credential record.
109 unset( $mcp_settings['app_password_authorization'] );
110 unset( $mcp_settings['app_password_uuid'] );
111 unset( $mcp_settings['app_password_user_id'] );
112 $mcp_settings['enabled'] = false;
113 update_option( 'zip_mcp_settings', $mcp_settings );
114
115 wp_send_json_success(
116 array(
117 'message' => 'Settings cleared successfully',
118 )
119 );
120 }
121
122 /**
123 * Verify authentication status (for polling during auth popup)
124 *
125 * @return void
126 */
127 public function zipwp_verify_auth_status() {
128 // Verify nonce.
129 if ( ! isset( $_POST['nonce'] ) || ! is_string( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'zip_ai_iframe' ) ) {
130 wp_send_json_error( 'Invalid nonce' );
131 }
132
133 // Capability check — parity with the other AJAX handlers in this class.
134 if ( ! current_user_can( 'manage_options' ) ) {
135 wp_send_json_error( 'Insufficient permissions' );
136 }
137
138 // Only treat the user as authorized when the stored token is actually decryptable.
139 $is_authorized = Helper::is_authorized();
140
141 // Return authorization status.
142 wp_send_json_success(
143 array(
144 'is_authorized' => $is_authorized,
145 )
146 );
147 }
148
149 /**
150 * Dismiss the inline setup notice for the current user (persists across
151 * reloads). The header warning icon still shows while setup is incomplete.
152 *
153 * @since 1.0.0
154 * @return void
155 */
156 public function zip_ai_dismiss_setup_gate() {
157 if ( ! isset( $_POST['nonce'] ) || ! is_string( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'zip_ai_iframe' ) ) {
158 wp_send_json_error( 'Security check failed' );
159 }
160
161 if ( ! current_user_can( 'manage_options' ) ) {
162 wp_send_json_error( 'Insufficient permissions' );
163 }
164
165 update_user_meta( get_current_user_id(), 'zip_ai_setup_gate_dismissed', 1 );
166 wp_send_json_success();
167 }
168
169 /**
170 * Activate the Spectra plugin or Spectra One theme for the setup gate.
171 *
172 * Done server-side via core APIs (activate_plugin / switch_theme) rather than
173 * fetching wp-admin activate URLs — those carry esc_html'd nonces ('&amp;')
174 * and referer checks that fail under a cross-context fetch.
175 *
176 * @since 1.0.0
177 * @return void
178 */
179 public function zip_ai_activate_setup_item() {
180 if ( ! isset( $_POST['nonce'] ) || ! is_string( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'zip_ai_iframe' ) ) {
181 wp_send_json_error( 'Security check failed' );
182 }
183
184 if ( ! current_user_can( 'manage_options' ) ) {
185 wp_send_json_error( 'Insufficient permissions' );
186 }
187
188 $type = isset( $_POST['type'] ) && is_string( $_POST['type'] ) ? sanitize_key( wp_unslash( $_POST['type'] ) ) : '';
189 $slug = isset( $_POST['slug'] ) && is_string( $_POST['slug'] ) ? sanitize_text_field( wp_unslash( $_POST['slug'] ) ) : '';
190
191 // Allowlist — this endpoint only activates the setup-gate items, never an
192 // arbitrary plugin/theme slug posted by the client.
193 $allowed = array(
194 'plugin' => array( 'spectra-blocks' ),
195 'theme' => array( 'spectra-one' ),
196 );
197 if ( ! isset( $allowed[ $type ] ) || ! in_array( $slug, $allowed[ $type ], true ) ) {
198 wp_send_json_error( 'This item cannot be activated here.' );
199 }
200
201 if ( 'plugin' === $type ) {
202 if ( ! current_user_can( 'activate_plugins' ) ) {
203 wp_send_json_error( 'Insufficient permissions' );
204 }
205 if ( ! function_exists( 'activate_plugin' ) || ! function_exists( 'get_plugins' ) ) {
206 require_once ABSPATH . 'wp-admin/includes/plugin.php';
207 }
208
209 $file = PluginResolver::resolve_plugin_file( $slug );
210 if ( null === $file ) {
211 wp_send_json_error( 'Plugin is not installed.' );
212 }
213
214 $result = activate_plugin( $file );
215 if ( is_wp_error( $result ) ) {
216 Utils::debug_log( sprintf( 'Plugin activation failed for "%s"', $slug ), $result->get_error_message() );
217 wp_send_json_error( "This plugin couldn't be activated. Please try activating it from wp-admin." );
218 }
219 wp_send_json_success( array( 'active' => is_plugin_active( $file ) ) );
220 }
221
222 if ( ! current_user_can( 'switch_themes' ) ) {
223 wp_send_json_error( 'Insufficient permissions' );
224 }
225 if ( ! wp_get_theme( $slug )->exists() ) {
226 wp_send_json_error( 'Theme is not installed.' );
227 }
228
229 switch_theme( $slug );
230 wp_send_json_success( array( 'active' => get_stylesheet() === $slug ) );
231 }
232
233 /**
234 * Handle OAuth callback and save auth token to zip_mcp_settings.
235 * Uses unique nonce (zip_ai_auth_nonce) and callback param (zip-ai-auth)
236 * to avoid conflict with UAG plugin's OAuth flow.
237 *
238 * Security: Uses OAuth state parameter for CSRF protection instead of WordPress nonce.
239 * The state parameter is generated before redirect and verified on callback.
240 *
241 * @since 1.0.0
242 * @return void
243 */
244 public function verify_authorization() {
245 // Check for our unique callback parameter - if not present, this is not our callback.
246 if ( ! isset( $_GET['zip-ai-auth'] ) || 'true' !== $_GET['zip-ai-auth'] ) {
247 return;
248 }
249
250 // If the current user does not have the required capability, then abandon ship.
251 if ( ! current_user_can( 'manage_options' ) ) {
252 return;
253 }
254
255 // If none of the required data is received, abandon ship.
256 if ( ! isset( $_GET['credit_token'] ) && ! isset( $_GET['token'] ) && ! isset( $_GET['email'] ) ) {
257 return;
258 }
259
260 $state = isset( $_GET['state'] ) && is_string( $_GET['state'] ) ? sanitize_text_field( $_GET['state'] ) : '';
261 if ( empty( $state ) ) {
262 return;
263 }
264
265 $stored_state = get_transient( 'zip_ai_oauth_state_' . get_current_user_id() );
266 if ( empty( $stored_state ) || ! is_string( $stored_state ) || ! hash_equals( $stored_state, $state ) ) {
267 return;
268 }
269
270 delete_transient( 'zip_ai_oauth_state_' . get_current_user_id() );
271
272 // Get the existing options.
273 $mcp_settings = get_option( 'zip_mcp_settings', array() );
274 if ( ! is_array( $mcp_settings ) ) {
275 $mcp_settings = array();
276 }
277 $email = isset( $_GET['email'] ) && is_string( $_GET['email'] ) ? sanitize_email( wp_unslash( $_GET['email'] ) ) : '';
278 $name = isset( $_GET['name'] ) && is_string( $_GET['name'] ) ? sanitize_text_field( wp_unslash( $_GET['name'] ) ) : '';
279 $zip_token = isset( $_GET['token'] ) && is_string( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : '';
280 $credit_token = isset( $_GET['credit_token'] ) && is_string( $_GET['credit_token'] ) ? sanitize_text_field( wp_unslash( $_GET['credit_token'] ) ) : '';
281 $team_uuid = isset( $_GET['team_uuid'] ) && is_string( $_GET['team_uuid'] ) ? sanitize_text_field( wp_unslash( $_GET['team_uuid'] ) ) : '';
282 $auth_token = '';
283
284 if ( '' !== $zip_token && '' !== $email ) {
285 $exchange_result = Helper::exchange_zipwp_token_for_local_auth_token( $zip_token, $email, $name, $team_uuid );
286
287 if ( ! empty( $exchange_result['token'] ) ) {
288 $auth_token = (string) $exchange_result['token'];
289 }
290
291 if ( ! empty( $exchange_result['email'] ) ) {
292 $email = $exchange_result['email'];
293 }
294
295 if ( ! empty( $exchange_result['name'] ) ) {
296 $name = $exchange_result['name'];
297 }
298 }
299
300 // Only accept credit_token when the credit server positively validates it.
301 // validate_credit_server_auth_token() returns true (valid), false (rejected),
302 // or null (unreachable) — fail closed on anything but an explicit true so an
303 // attacker who can block/spoof the validate endpoint can't inject a token.
304 if ( '' === $auth_token && '' !== $credit_token && true === Helper::validate_credit_server_auth_token( $credit_token ) ) {
305 $auth_token = $credit_token;
306 }
307
308 // Update the auth token if needed.
309 if ( '' !== $auth_token ) {
310 $mcp_settings['auth_token'] = Utils::encrypt( $auth_token );
311 $mcp_settings['auth_token_server'] = untrailingslashit( ZIPAI_MCP_CREDIT_SERVER_API );
312
313 // Remember the team so a later re-exchange
314 // (Helper::get_decrypted_auth_token) sends the same one without
315 // another round trip through ZipWP. Only when the callback carries
316 // one, so a legacy redirect never clears it.
317 //
318 // Gated on a SUCCESSFUL connection, alongside auth_token_server,
319 // because the team must follow the token it belongs to. Written
320 // earlier it survived a FAILED exchange: a callback carrying team B
321 // whose exchange failed left auth_token = A with team_uuid = B, and
322 // a later re-exchange then sent B for A's token — the cross-tenant
323 // mispointing this change exists to prevent.
324 if ( '' !== $team_uuid ) {
325 $mcp_settings['team_uuid'] = $team_uuid;
326 }
327 }
328
329 // Update the ZIP AI token if needed.
330 if ( '' !== $zip_token ) {
331 $mcp_settings['zip_token'] = Utils::encrypt( $zip_token );
332 }
333
334 // Update the email if needed.
335 if ( '' !== $email ) {
336 $mcp_settings['user_email'] = $email;
337 }
338
339 // Update the user name if needed.
340 if ( '' !== $name ) {
341 $mcp_settings['user_name'] = $name;
342 }
343
344 // Mark as enabled.
345 $mcp_settings['enabled'] = true;
346 $mcp_settings['authenticated_at'] = current_time( 'mysql' );
347
348 // Update the zip_mcp_settings option.
349 update_option( 'zip_mcp_settings', $mcp_settings );
350
351 // Mint a WordPress Application Password for the server → WP MCP
352 // transport on the same connection click. Piggy-backs on the
353 // existing OAuth flow so the admin sees one "Connect" step.
354 // Failure here is non-fatal for the connection itself (Sanctum
355 // auth still works); MCP tool execution will surface a
356 // clear error if the App Password is missing. The admin can
357 // retry via the disconnect → reconnect path.
358 if ( '' !== $auth_token ) {
359 $provision = Helper::ensure_app_password_provisioned();
360 if ( empty( $provision['success'] ) ) {
361 $code = isset( $provision['code'] ) ? $provision['code'] : 'unknown';
362 error_log( sprintf( '[zip-ai] App Password provisioning failed on connect: %s', $code ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
363 }
364
365 // The site just connected to ERA — enable MCP for every already-active
366 // first-party plugin the toggler maps (e.g. SureRank). Covers the case
367 // where a mapped plugin was active BEFORE this connect, so its own
368 // `activated_plugin` never ran the toggler. Deliberately outside the
369 // provisioning result: the sweep has no dependency on App Passwords,
370 // and a reconnect returns `already_provisioned`. Idempotent.
371 Plugin_Abilities_Toggler::sweep_active_mapped_plugins();
372 }
373
374 // Run initial site scan after successful auth — gives ZIP AI day-zero context.
375 // Fire non-blocking request to local REST endpoint (avoids WP cron unreliability).
376 if ( '' !== $auth_token ) {
377 \ZipAI\MCP\Classes\Core\Site_Scanner::fire_scan_request();
378 }
379
380 // Redirect back to the assistant screen after successful authentication.
381 // This prevents users from landing on a generic admin URL with callback query params.
382 wp_safe_redirect( admin_url( 'options-general.php?page=zip-ai-assistant' ) );
383 exit;
384 }
385
386 /**
387 * Upload a base64 image to the WordPress Media Library.
388 *
389 * Expects POST params: nonce, base64_data, mime_type.
390 *
391 * @since 0.0.5
392 * @return void
393 */
394 public function zip_ai_upload_media() {
395 // Check nonce for security.
396 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
397 $nonce = isset( $_POST['nonce'] ) && is_string( $_POST['nonce'] ) ? $_POST['nonce'] : '';
398 if ( ! wp_verify_nonce( $nonce, 'zip_ai_iframe' ) ) {
399 wp_send_json_error( array( 'message' => 'Security check failed.' ), 403 );
400 }
401
402 if ( ! current_user_can( 'upload_files' ) ) {
403 wp_send_json_error( array( 'message' => 'You do not have permission to upload files.' ), 403 );
404 }
405
406 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- raw base64 decoded server-side
407 $base64_data = isset( $_POST['base64_data'] ) && is_string( $_POST['base64_data'] ) ? $_POST['base64_data'] : '';
408 $mime_type = isset( $_POST['mime_type'] ) && is_string( $_POST['mime_type'] ) ? sanitize_text_field( $_POST['mime_type'] ) : '';
409
410 if ( empty( $base64_data ) || empty( $mime_type ) ) {
411 wp_send_json_error( array( 'message' => 'Missing base64 data or MIME type.' ), 400 );
412 }
413
414 $media_service = new \ZipAI\MCP\Classes\Services\Media_Service();
415 $attachment_id = $media_service->upload_from_base64( $base64_data, $mime_type );
416
417 if ( is_wp_error( $attachment_id ) ) {
418 Utils::debug_log( 'Media upload failed', $attachment_id->get_error_message() );
419 wp_send_json_error( array( 'message' => "That image couldn't be uploaded. Please try again." ), 500 );
420 }
421
422 wp_send_json_success(
423 array(
424 'attachment_id' => $attachment_id,
425 'url' => wp_get_attachment_url( $attachment_id ),
426 )
427 );
428 }
429 }
430