PluginProbe ʕ •ᴥ•ʔ
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). / trunk
CommerceBird – AI Command Center, ERP Integrations & B2B for WooCommerce (Zoho, Exact Online). vtrunk
3.0.3 3.0.2 3.0.1 trunk 2.2.14 2.2.15 2.2.16 2.2.17 2.2.18 2.2.19 2.3.0 2.3.1 2.3.10 2.3.11 2.3.12 2.3.13 2.3.14 2.3.2 2.3.3 2.3.4 2.3.5 2.3.6 2.3.7 2.3.8 2.3.9 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1 2.5.2 2.6.0 2.6.1 2.6.2 2.6.3 2.6.4 2.6.5 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 2.7.91 2.7.92 2.7.93 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.8.5 2.9.0 2.9.1 2.9.2 2.9.3 3.0.0
commercebird / admin / includes / Actions / Ajax / SettingsAjax.php
commercebird / admin / includes / Actions / Ajax Last commit date
AcfAjax.php 1 month ago ExactOnlineAjax.php 5 days ago SettingsAjax.php 1 month ago ZohoCRMAjax.php 4 months ago ZohoInventoryAjax.php 5 days ago index.php 1 year ago
SettingsAjax.php
553 lines
1 <?php
2 /**
3 * CommerceBird Settings Ajax Handler
4 *
5 * @package CommerceBird\Admin\Actions\Ajax
6 */
7 namespace CommerceBird\Admin\Actions\Ajax;
8
9 use CommerceBird\Admin\Connectors\Connector;
10 use CommerceBird\Admin\Traits\AjaxRequest;
11 use CommerceBird\Admin\Traits\OptionStatus;
12 use CommerceBird\Admin\Traits\Singleton;
13 use CommerceBird\Admin\Traits\LogWriter;
14 use CommerceBird\CMBIRD_AI_Product_Blocks_Cron;
15 use WpOrg\Requests\Exception;
16
17 if ( ! defined( 'ABSPATH' ) ) {
18 exit;
19 }
20 final class SettingsAjax {
21
22
23 use Singleton;
24 use AjaxRequest;
25 use OptionStatus;
26 use LogWriter;
27
28 private const FORMS = array(
29 'settings' => array(
30 'id',
31 'email',
32 'token',
33 ),
34 'ai_features' => array(
35 'reviews',
36 'faq',
37 'shopping',
38 ),
39 );
40
41 private const ACTIONS = array(
42 'get_subscription' => 'subscription_get',
43 'get_settings' => 'settings_get',
44 'save_settings' => 'settings_set',
45 'reset_settings' => 'settings_reset',
46 'get_ai_features' => 'ai_features_get',
47 'save_ai_features' => 'ai_features_set',
48 );
49
50 private const OPTIONS = array(
51 'settings' => array(
52 'token' => 'commercebird-exact-online-token',
53 'id' => 'commercebird-subscription-id',
54 'email' => 'commercebird-woo-webhook-status',
55 ),
56 'ai_features' => array(
57 'reviews' => 'cmbird_ai_review_summary_enabled',
58 'faq' => 'cmbird_ai_content_writer_enabled',
59 'shopping' => 'cmbird_ai_shopping_experience_enabled',
60 ),
61 );
62
63 /**
64 * SettingsAjax constructor.
65 */
66 public function __construct() {
67 $this->load_actions();
68 }
69
70 /**
71 * Retrieves the subscription details with enhanced security.
72 *
73 * @return void
74 */
75 public function subscription_get(): void {
76 $this->verify();
77
78 // Validate subscription ID and email ownership.
79 $subscription_id = get_option( self::OPTIONS['settings']['id'], 0 );
80 $stored_email = get_option( self::OPTIONS['settings']['email'], '' );
81
82 if ( empty( $subscription_id ) || empty( $stored_email ) ) {
83 $this->errors = array( 'message' => 'Missing subscription ID or email' );
84 $this->serve();
85 return;
86 }
87
88 $subscription_data = $this->get_subscription_data();
89
90 // Server-side email validation - critical security check.
91 if ( isset( $subscription_data['billing']['email'] ) ) {
92 if ( $subscription_data['billing']['email'] !== $stored_email ) {
93 $this->errors = array(
94 'message' => 'Email mismatch: Access denied',
95 'code' => 'email_mismatch',
96 );
97
98 // Log potential security breach attempt.
99 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Required for security logging.
100 error_log(
101 sprintf(
102 'CommerceBird Security: Email mismatch attempt. User ID: %d, IP: %s, Stored: %s, Attempted: %s',
103 get_current_user_id(),
104 isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : 'unknown',
105 $stored_email,
106 $subscription_data['billing']['email']
107 )
108 ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Security logging.
109
110 $this->serve();
111 return;
112 }
113 } else {
114 // If no subscription data or no billing email, return empty structure.
115 $this->response = $this->get_empty_subscription_structure();
116 $this->serve();
117 return;
118 }
119
120 // Remove sensitive data before sending to frontend.
121 $safe_data = $this->sanitize_subscription_data( $subscription_data );
122 $this->response = $safe_data;
123 $this->serve();
124 }
125
126 /**
127 * @description Function to get subscription data from CommerceBird API.
128 */
129 public function get_subscription_data(): array {
130 $transient = get_transient( 'subscription_details' );
131 if ( ! empty( $transient ) ) {
132 return $transient;
133 }
134 $subscription_id = get_option( self::OPTIONS['settings']['id'], 0 );
135 $data = ( new Connector() )->get_subscription(
136 array(
137 'subscriptionId' => $subscription_id,
138 )
139 );
140 if ( is_wp_error( $data ) ) {
141 return array(
142 'status' => 'error',
143 'message' => $data->get_error_message(),
144 );
145 }
146 if ( ! empty( $data ) && 'error' !== $data['status'] ) {
147 $data['variation_id'] = array_column( isset( $data['line_items'] ) ? $data['line_items'] : array(), 'variation_id' );
148 $plan_names = array_column( isset( $data['line_items'] ) ? $data['line_items'] : array(), 'name' );
149 $data['plan'] = implode( ', ', $plan_names );
150 set_transient( 'subscription_details', $data, WEEK_IN_SECONDS );
151
152 // Store subscription status for admin notices.
153 update_option( 'commercebird-subscription-status', $data['status'], false );
154 } else {
155 $data = array(
156 'status' => 'error',
157 'message' => $data['message'],
158 );
159 }
160 return $data;
161 }
162
163
164 /**
165 * Resets the settings details.
166 *
167 * @return void
168 */
169 public function settings_reset(): void {
170 $this->verify();
171 delete_option( self::OPTIONS['settings']['token'] );
172 delete_option( self::OPTIONS['settings']['id'] );
173 delete_option( self::OPTIONS['settings']['email'] );
174 $this->response = array( 'message' => 'Reset successfully!' );
175 $this->serve();
176 }
177
178 /**
179 * Retrieves the settings details.
180 *
181 * @return void
182 */
183 public function settings_get(): void {
184 $this->verify();
185 $token = get_option( self::OPTIONS['settings']['token'], '' );
186 $id = get_option( self::OPTIONS['settings']['id'], '' );
187 $email = get_option( self::OPTIONS['settings']['email'], '' );
188 $this->response['token'] = $token;
189 $this->response['id'] = $id;
190 $this->response['email'] = $email;
191 $this->serve();
192 }
193
194 /**
195 * Retrieves AI feature toggle settings.
196 *
197 * @return void
198 */
199 public function ai_features_get(): void {
200 $this->verify();
201
202 $this->response = array(
203 'reviews' => (bool) get_option( self::OPTIONS['ai_features']['reviews'], false ),
204 'faq' => (bool) get_option( self::OPTIONS['ai_features']['faq'], false ),
205 'shopping' => (bool) get_option( self::OPTIONS['ai_features']['shopping'], false ),
206 'ai_connector_active' => $this->is_ai_connector_active(),
207 );
208
209 // Keep legacy key for backward compatibility with older admin builds.
210 $this->response['writer'] = $this->response['faq'];
211
212 $this->serve();
213 }
214
215 /**
216 * Determines whether at least one WordPress AI connector can generate text.
217 *
218 * AI feature blocks rely on the native WordPress AI Connector. A connector is
219 * only usable when it actually supports text generation, so we probe each
220 * registered connector via the AI client. Connectors that cannot generate text
221 * (e.g. "akismet") are ignored.
222 *
223 * @return bool True when one or more connectors support text generation.
224 */
225 private function is_ai_connector_active(): bool {
226 if (
227 ! function_exists( 'wp_supports_ai' )
228 || ! function_exists( 'wp_get_connectors' )
229 || ! function_exists( 'wp_ai_client_prompt' )
230 ) {
231 return false;
232 }
233
234 // Environment-level gate; never throws.
235 if ( ! wp_supports_ai() ) {
236 return false;
237 }
238
239 $connectors = wp_get_connectors();
240 if ( ! is_array( $connectors ) || empty( $connectors ) ) {
241 return false;
242 }
243
244 foreach ( $connectors as $connector_id => $connector ) {
245 /*
246 * The AI client builder's magic __call() only catches \Exception, so a
247 * \Error/\TypeError raised while probing a connector (e.g. a non-AI
248 * connector such as akismet, an unconfigured provider, or registry
249 * resolution) would otherwise bubble up as a fatal "critical error".
250 * Contain it per connector and treat a failure as "not text-capable".
251 */
252 try {
253 $builder = wp_ai_client_prompt( 'test' )->using_provider( (string) $connector_id );
254
255 // Claude (Anthropic) models reject the temperature option, which would make
256 // the support check fail for an otherwise capable provider. Only set the
257 // temperature for providers that accept it.
258 if ( ! $this->connector_rejects_temperature( (string) $connector_id, (array) $connector ) ) {
259 $builder = $builder->using_temperature( 0.7 );
260 }
261
262 if ( $builder->is_supported_for_text_generation() ) {
263 return true;
264 }
265 } catch ( \Throwable $e ) {
266 continue;
267 }
268 }
269
270 return false;
271 }
272
273 /**
274 * Determines whether a connector's model rejects the temperature option.
275 *
276 * Anthropic (Claude) models do not accept a temperature parameter, so it must
277 * be omitted from the text-generation support check for them.
278 *
279 * @param string $connector_id Connector ID from wp_get_connectors().
280 * @param array<string, mixed> $connector Connector settings.
281 * @return bool True when the temperature option must not be applied.
282 */
283 private function connector_rejects_temperature( string $connector_id, array $connector ): bool {
284 $haystack = strtolower( $connector_id . ' ' . ( isset( $connector['name'] ) ? (string) $connector['name'] : '' ) );
285
286 return str_contains( $haystack, 'anthropic' ) || str_contains( $haystack, 'claude' );
287 }
288
289 /**
290 * Sets the settings for the class with enhanced validation.
291 *
292 * @return void
293 */
294 public function settings_set(): void {
295 $this->verify( self::FORMS['settings'] );
296
297 try {
298 if ( $this->data ) {
299 // Enhanced input validation.
300 $id = $this->validate_subscription_id( $this->data['id'] ?? '' );
301 $email = $this->validate_email( $this->data['email'] ?? '' );
302 $token = $this->validate_token( $this->data['token'] ?? '' );
303
304 if ( ! $id || ! $email || ! $token ) {
305 $this->errors = array( 'message' => 'Invalid input data provided' );
306 $this->serve();
307 return;
308 }
309
310 // Update options with validated data.
311 update_option( self::OPTIONS['settings']['id'], $id );
312 update_option( self::OPTIONS['settings']['email'], $email );
313 update_option( self::OPTIONS['settings']['token'], $token );
314
315 // Clear cached subscription data when settings change.
316 delete_transient( 'subscription_details' );
317
318 $this->response = array( 'message' => 'Settings saved successfully' );
319 } else {
320 $this->errors = array( 'message' => 'No data provided' );
321 }
322 } catch ( Exception $exception ) {
323 $this->errors = array( 'message' => 'Failed to save settings' );
324 }
325 $this->serve();
326 }
327
328 /**
329 * Saves AI feature toggle settings as boolean values.
330 *
331 * @return void
332 */
333 public function ai_features_set(): void {
334 $this->verify( self::FORMS['ai_features'] );
335
336 if ( empty( $this->data ) || ! is_array( $this->data ) ) {
337 $this->errors = array( 'message' => 'No AI feature data provided' );
338 $this->serve();
339 return;
340 }
341
342 $connector_active = $this->is_ai_connector_active();
343
344 // Without an active WordPress AI connector the features cannot run, so we
345 // refuse to enable any of them regardless of the incoming payload.
346 $reviews_enabled = $connector_active && $this->normalize_boolean( $this->data['reviews'] ?? false );
347 $faq_enabled = $connector_active && $this->normalize_boolean( $this->data['faq'] ?? ( $this->data['writer'] ?? false ) );
348 $shopping_enabled = $connector_active && $this->normalize_boolean( $this->data['shopping'] ?? false );
349
350 update_option( self::OPTIONS['ai_features']['reviews'], $reviews_enabled );
351 update_option( self::OPTIONS['ai_features']['faq'], $faq_enabled );
352 update_option( self::OPTIONS['ai_features']['shopping'], $shopping_enabled );
353
354 if ( $reviews_enabled ) {
355 CMBIRD_AI_Product_Blocks_Cron::activate();
356 } else {
357 CMBIRD_AI_Product_Blocks_Cron::deactivate();
358 }
359
360 $this->response = array(
361 'message' => $connector_active
362 ? 'AI feature settings saved successfully'
363 : 'No active WordPress AI Connector found. Configure one to enable AI features.',
364 'reviews' => $reviews_enabled,
365 'faq' => $faq_enabled,
366 'shopping' => $shopping_enabled,
367 'ai_connector_active' => $connector_active,
368 );
369
370 // Keep legacy key for backward compatibility with older admin builds.
371 $this->response['writer'] = $this->response['faq'];
372
373 $this->serve();
374 }
375
376 /**
377 * Convert mixed values into canonical booleans.
378 *
379 * @param mixed $value Input value from request payload.
380 * @return bool
381 */
382 private function normalize_boolean( $value ): bool {
383 if ( is_bool( $value ) ) {
384 return $value;
385 }
386
387 if ( is_int( $value ) ) {
388 return 1 === $value;
389 }
390
391 if ( is_string( $value ) ) {
392 $normalized = strtolower( trim( $value ) );
393 return in_array( $normalized, array( '1', 'true', 'yes', 'on' ), true );
394 }
395
396 return false;
397 }
398
399 /**
400 * Validate subscription ID format.
401 *
402 * @param string $id Subscription ID to validate.
403 * @return string|false Validated ID or false if invalid.
404 */
405 private function validate_subscription_id( $id ) {
406 $id = sanitize_text_field( $id );
407 return is_numeric( $id ) && $id > 0 ? $id : false;
408 }
409
410 /**
411 * Validate email format and sanitize.
412 *
413 * @param string $email Email to validate.
414 * @return string|false Validated email or false if invalid.
415 */
416 private function validate_email( $email ) {
417 $email = sanitize_email( $email );
418 return is_email( $email ) ? $email : false;
419 }
420
421 /**
422 * Validate token format.
423 *
424 * @param string $token Token to validate.
425 * @return string|false Validated token or false if invalid.
426 */
427 private function validate_token( $token ) {
428 $token = sanitize_text_field( $token );
429 // Basic token validation - adjust pattern as needed.
430 return preg_match( '/^[a-zA-Z0-9_-]+$/', $token ) ? $token : false;
431 }
432
433 /**
434 * Sanitize subscription data before sending to frontend.
435 *
436 * @param array $data Raw subscription data.
437 * @return array Sanitized data.
438 */
439 /**
440 * Sanitize subscription data before sending to frontend.
441 *
442 * @param array $data Raw subscription data.
443 * @return array Sanitized data.
444 */
445 private function sanitize_subscription_data( $data ) {
446 if ( ! is_array( $data ) ) {
447 return array();
448 }
449
450 // Remove or hash sensitive billing information.
451 if ( isset( $data['billing'] ) ) {
452 $safe_billing = array();
453 // Include safe billing fields that frontend expects.
454 $safe_billing_fields = array( 'first_name', 'company', 'country', 'email' );
455 foreach ( $safe_billing_fields as $billing_field ) {
456 if ( isset( $data['billing'][ $billing_field ] ) ) {
457 if ( 'email' === $billing_field ) {
458 $safe_billing[ $billing_field ] = sanitize_email( $data['billing'][ $billing_field ] );
459 } else {
460 $safe_billing[ $billing_field ] = sanitize_text_field( $data['billing'][ $billing_field ] );
461 }
462 }
463 }
464 $data['billing'] = $safe_billing;
465 }
466
467 // Define all safe fields that the frontend expects.
468 $safe_fields = array(
469 'status',
470 'plan',
471 'currency',
472 'total',
473 'needs_payment',
474 'next_payment_date_gmt',
475 'payment_url',
476 'variation_id',
477 );
478 $sanitized = array();
479
480 foreach ( $safe_fields as $field ) {
481 if ( isset( $data[ $field ] ) ) {
482 if ( is_array( $data[ $field ] ) ) {
483 $sanitized[ $field ] = array_map( 'sanitize_text_field', $data[ $field ] );
484 } elseif ( 'needs_payment' === $field ) {
485 $sanitized[ $field ] = (bool) $data[ $field ];
486 } else {
487 $sanitized[ $field ] = sanitize_text_field( $data[ $field ] );
488 }
489 }
490 }
491
492 // Handle fee_lines array specially since it contains objects.
493 if ( isset( $data['fee_lines'] ) && is_array( $data['fee_lines'] ) ) {
494 $sanitized['fee_lines'] = array();
495 foreach ( $data['fee_lines'] as $fee_line ) {
496 if ( is_array( $fee_line ) ) {
497 $sanitized_fee = array();
498 $fee_fields = array( 'id', 'name', 'tax_class', 'tax_status' );
499 foreach ( $fee_fields as $fee_field ) {
500 if ( isset( $fee_line[ $fee_field ] ) ) {
501 if ( 'id' === $fee_field ) {
502 $sanitized_fee[ $fee_field ] = (int) $fee_line[ $fee_field ];
503 } else {
504 $sanitized_fee[ $fee_field ] = sanitize_text_field( $fee_line[ $fee_field ] );
505 }
506 }
507 }
508 $sanitized['fee_lines'][] = $sanitized_fee;
509 }
510 }
511 }
512
513 // Include billing data.
514 if ( isset( $data['billing'] ) ) {
515 $sanitized['billing'] = $data['billing'];
516 }
517
518 return $sanitized;
519 }
520
521 /**
522 * Get empty subscription structure that matches frontend expectations.
523 *
524 * @return array Empty subscription structure.
525 */
526 private function get_empty_subscription_structure(): array {
527 return array(
528 'status' => '',
529 'currency' => '',
530 'total' => '',
531 'fee_lines' => array(),
532 'payment_url' => '',
533 'needs_payment' => false,
534 'next_payment_date_gmt' => '',
535 'variation_id' => array(),
536 'plan' => '',
537 'billing' => array(
538 'first_name' => '',
539 'company' => '',
540 'country' => '',
541 'email' => '',
542 ),
543 );
544 }
545
546 /**
547 * Function to get the token from the settings.
548 */
549 public function get_token() {
550 return get_option( self::OPTIONS['settings']['token'], '' );
551 }
552 }
553