PluginProbe
WowAddons – Product Addons and Product Options With Custom Fields / trunk
WowAddons – Product Addons and Product Options With Custom Fields vtrunk
1.7.2 1.7.1 1.7.0 1.6.21 1.6.20 1.6.19 1.6.18 1.6.17 1.6.16 1.6.15 1.6.14 1.6.13 1.6.12 1.6.11 1.6.10 1.6.9 1.6.8 1.6.7 1.6.6 1.5.10 1.5.11 1.5.2 1.5.3 1.5.4 1.5.5 All 57 releases
product-addons / includes / restapi / class-request-api.php

class-request-api.php in WowAddons – Product Addons and Product Options With Custom Fields trunk, at includes/restapi/class-request-api.php

2,617 lines 80.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php // phpcs:ignore
2 /**
3 * RequestAPI Action.
4 *
5 * @package PRAD\Options
6 * @since 1.0.0
7 */
8
9 namespace PRAD\Includes\Restapi;
10
11 use PRAD\Includes\Admin\Durbin\DurbinClient;
12 use PRAD\Includes\Analytics;
13 use PRAD\Includes\Xpo;
14 use WP_REST_Response;
15 use WC_Data_Store;
16
17 defined( 'ABSPATH' ) || exit;
18 /**
19 * RequestAPI class to handle API requests.
20 *
21 * @since 1.0.0
22 */
23 class RequestApi {
24
25
26 private $extra_upload_field_mimes = array();
27
28 /**
29 * Initialize the RequestAPI class
30 *
31 * @since 1.0.0
32 */
33 public function __construct() {
34 add_action( 'rest_api_init', array( $this, 'register_route' ) );
35 }
36
37 /**
38 * Register hook
39 *
40 * @since 1.0.0
41 */
42 public function register_route() {
43 $routes = array(
44 // Single Product Page file upload.
45 array(
46 'endpoint' => 'upload-file',
47 'methods' => 'POST',
48 'callback' => array( $this, 'upload_files_callback' ),
49 'permission_callback' => '__return_true',
50 ),
51 array(
52 'endpoint' => 'set_analytics',
53 'methods' => 'POST',
54 'callback' => array( $this, 'set_analytics_data_callback' ),
55 'permission_callback' => '__return_true',
56 ),
57
58 // Get Analytics Data.
59 array(
60 'endpoint' => 'get_analytics',
61 'methods' => 'GET',
62 'callback' => array( $this, 'get_analytics_data_callback' ),
63 'permission_callback' => array( $this, 'prad_get_view_only_permissions' ),
64 ),
65
66 // Backend Option Listing.
67 array(
68 'endpoint' => 'option_list',
69 'methods' => 'POST',
70 'callback' => array( $this, 'option_listing_callback' ),
71 'permission_callback' => array( $this, 'prad_get_view_only_permissions' ),
72 ),
73 // Duplicate List.
74 array(
75 'endpoint' => 'list_duplicate',
76 'methods' => 'POST',
77 'callback' => array( $this, 'list_duplicate_callback' ),
78 'permission_callback' => array( $this, 'prad_get_admin_permissions' ),
79 ),
80 // Import List.
81 array(
82 'endpoint' => 'list_import',
83 'methods' => 'POST',
84 'callback' => array( $this, 'list_import_callback' ),
85 'permission_callback' => array( $this, 'prad_get_admin_permissions' ),
86 ),
87 // Delete List.
88 array(
89 'endpoint' => 'list_delete',
90 'methods' => 'POST',
91 'callback' => array( $this, 'list_delete_callback' ),
92 'permission_callback' => array( $this, 'prad_get_admin_permissions' ),
93 ),
94 // Update List.
95 array(
96 'endpoint' => 'list_update',
97 'methods' => 'POST',
98 'callback' => array( $this, 'list_update_callback' ),
99 'permission_callback' => array( $this, 'prad_get_admin_permissions' ),
100 ),
101 // Get Option Edit Page Data.
102 array(
103 'endpoint' => 'get_option',
104 'methods' => 'POST',
105 'callback' => array( $this, 'get_option_callback' ),
106 'permission_callback' => array( $this, 'prad_get_view_only_permissions' ),
107 ),
108 // Save Option Edit Page Data.
109 array(
110 'endpoint' => 'set_option',
111 'methods' => 'POST',
112 'callback' => array( $this, 'set_option_callback' ),
113 'permission_callback' => array( $this, 'prad_get_admin_permissions' ),
114 ),
115 // Option Assign Search Products.
116 array(
117 'endpoint' => 'assign_search',
118 'methods' => 'POST',
119 'callback' => array( $this, 'assign_search_callback' ),
120 'permission_callback' => array( $this, 'prad_get_view_only_permissions' ),
121 ),
122 array(
123 'endpoint' => 'product_search',
124 'methods' => 'POST',
125 'callback' => array( $this, 'get_product_search_callback' ),
126 'permission_callback' => array( $this, 'prad_get_view_only_permissions' ),
127 ),
128 array(
129 'endpoint' => 'products_details',
130 'methods' => 'POST',
131 'callback' => array( $this, 'get_products_details_callback' ),
132 'permission_callback' => array( $this, 'prad_get_view_only_permissions' ),
133 ),
134 // Get Assign Data.
135 array(
136 'endpoint' => 'get_assign',
137 'methods' => 'POST',
138 'callback' => array( $this, 'get_assign_product_callback' ),
139 'permission_callback' => array( $this, 'prad_get_view_only_permissions' ),
140 ),
141 // Set Assign Data.
142 array(
143 'endpoint' => 'set_assign',
144 'methods' => 'POST',
145 'callback' => array( $this, 'set_assign_product_callback' ),
146 'permission_callback' => array( $this, 'prad_get_admin_permissions' ),
147 ),
148 // Get Global Settings.
149 array(
150 'endpoint' => 'get_global',
151 'methods' => 'GET',
152 'callback' => array( $this, 'get_global_callback' ),
153 'permission_callback' => array( $this, 'prad_get_view_only_permissions' ),
154 ),
155 // Set Global Settings.
156 array(
157 'endpoint' => 'set_global',
158 'methods' => 'POST',
159 'callback' => array( $this, 'set_global_callback' ),
160 'permission_callback' => array( $this, 'prad_get_admin_permissions' ),
161 ),
162
163 // Get Global Settings.
164 array(
165 'endpoint' => 'get_settings',
166 'methods' => 'GET',
167 'callback' => array( $this, 'get_settings_callback' ),
168 'permission_callback' => array( $this, 'prad_get_view_only_permissions' ),
169 ),
170 // Set Global Settings.
171 array(
172 'endpoint' => 'set_settings',
173 'methods' => 'POST',
174 'callback' => array( $this, 'set_settings_callback' ),
175 'permission_callback' => array( $this, 'prad_get_admin_permissions' ),
176 ),
177 // Set Global Settings.
178 array(
179 'endpoint' => 'install_plugin',
180 'methods' => 'POST',
181 'callback' => array( $this, 'install_plugin_callback' ),
182 'permission_callback' => function () {
183 return current_user_can( 'manage_options' );
184 },
185 ),
186 // Product Image Compatibility.
187 array(
188 'endpoint' => 'product_image',
189 'methods' => 'POST',
190 'callback' => array( $this, 'product_image_callback' ),
191 'permission_callback' => array( $this, 'prad_get_admin_permissions' ),
192 ),
193 // Sideload External Image to Media Library.
194 array(
195 'endpoint' => 'sideload_image',
196 'methods' => 'POST',
197 'callback' => array( $this, 'sideload_image_callback' ),
198 'permission_callback' => function () {
199 return current_user_can( 'manage_options' );
200 },
201 ),
202
203 // Font Upload.
204 array(
205 'endpoint' => 'upload_font',
206 'methods' => 'POST',
207 'callback' => array( $this, 'upload_font_callback' ),
208 'permission_callback' => array( $this, 'prad_get_admin_permissions' ),
209 ),
210 // Get Fonts List.
211 array(
212 'endpoint' => 'get_fonts',
213 'methods' => 'GET',
214 'callback' => array( $this, 'get_fonts_callback' ),
215 'permission_callback' => array( $this, 'prad_get_view_only_permissions' ),
216 ),
217 // Delete Font.
218 array(
219 'endpoint' => 'delete_font',
220 'methods' => 'POST',
221 'callback' => array( $this, 'delete_font_callback' ),
222 'permission_callback' => array( $this, 'prad_get_admin_permissions' ),
223 ),
224 // Update Font.
225 array(
226 'endpoint' => 'update_font',
227 'methods' => 'POST',
228 'callback' => array( $this, 'update_font_callback' ),
229 'permission_callback' => array( $this, 'prad_get_admin_permissions' ),
230 ),
231
232 array(
233 'endpoint' => 'product_link',
234 'methods' => 'POST',
235 'callback' => array( $this, 'get_product_link_callback' ),
236 'permission_callback' => array( $this, 'prad_get_view_only_permissions' ),
237 ),
238
239 // Dismiss the builder onboarding tour.
240 array(
241 'endpoint' => 'dismiss_tour',
242 'methods' => 'POST',
243 'callback' => array( $this, 'dismiss_tour_callback' ),
244 'permission_callback' => array( $this, 'prad_get_admin_permissions' ),
245 ),
246 array(
247 'endpoint' => 'durbin_subscribe',
248 'methods' => 'POST',
249 'callback' => array( $this, 'durbin_subscribe_callback' ),
250 'permission_callback' => array( $this, 'prad_get_admin_permissions' ),
251 ),
252 );
253
254 foreach ( $routes as $route ) {
255 register_rest_route(
256 'prad',
257 $route['endpoint'],
258 array(
259 array(
260 'methods' => $route['methods'],
261 'callback' => $route['callback'],
262 'permission_callback' => $route['permission_callback'],
263 ),
264 )
265 );
266 }
267 }
268
269 /**
270 * Check permissions for endpoint.
271 *
272 * @return bool
273 */
274 public function prad_get_view_only_permissions() {
275 return current_user_can( Xpo::prad_old_view_permisson_handler() );
276 }
277 /**
278 * Check permissions for Edit, Update endpoint.
279 *
280 * @return bool
281 */
282 public function prad_get_admin_permissions() {
283 return current_user_can( Xpo::prad_manage_admin_permisson_handler() );
284 }
285
286 /**
287 * Retrieves option data for a given post ID.
288 *
289 * @since 1.0.0
290 *
291 * @param \WP_REST_Request $request The REST API request object.
292 * @return WP_REST_Response Response containing option data or an error message.
293 */
294 public function get_option_callback( \WP_REST_Request $request ) {
295 $params = $request->get_params();
296 $id = isset( $params['id'] ) ? sanitize_text_field( $params['id'] ) : 0;
297 $post = get_post( $id );
298
299 if ( ! $post ) {
300 return new WP_REST_Response(
301 array(
302 'success' => false,
303 'message' => __( 'Post not found.', 'product-addons' ),
304 ),
305 404
306 );
307 }
308
309 // Prepare the response data.
310 $data = array(
311 'id' => $post->ID,
312 'title' => $post->post_title,
313 'status' => $post->post_status,
314 'content' => get_post_meta( $id, 'prad_addons_blocks', true ),
315 'error' => json_last_error_msg(),
316 );
317
318 // Return the success response with the post data.
319 return new WP_REST_Response(
320 array(
321 'success' => true,
322 'post' => $data,
323 ),
324 200
325 );
326 }
327
328 /**
329 * Updates or creates option data.
330 *
331 * @since 1.0.0
332 *
333 * @param \WP_REST_Request $request The REST API request object.
334 * @return WP_REST_Response Response indicating success or failure.
335 */
336 public function set_option_callback( \WP_REST_Request $request ) {
337 // Retrieve and sanitize request parameters.
338 $params = $request->get_params();
339 $id = isset( $params['id'] ) ? sanitize_text_field( $params['id'] ) : '';
340 $title = isset( $params['title'] ) ? sanitize_text_field( $params['title'] ) : 'Untitled';
341 $status = isset( $params['status'] ) ? sanitize_text_field( $params['status'] ) : 'draft';
342 $content = isset( $params['content'] ) && is_array( $params['content'] ) ? product_addons()->sanitize_rest_params( $params['content'] ) : '';
343 $required_fields = isset( $params['required_fields'] ) && is_array( $params['required_fields'] ) ? product_addons()->sanitize_rest_params( $params['required_fields'] ) : '';
344 $css = isset( $params['css'] ) ? product_addons()->sanitize_rest_params( $params['css'] ) : '';
345 $nonce = isset( $params['wpnonce'] ) ? sanitize_text_field( $params['wpnonce'] ) : '';
346
347 if ( ! $nonce || ! wp_verify_nonce( $nonce, 'prad-nonce' ) ) {
348 return new WP_REST_Response(
349 array(
350 'success' => false,
351 'message' => __( 'Invalid or missing nonce.', 'product-addons' ),
352 ),
353 403
354 );
355 }
356
357 // Prepare the attributes for the post.
358 $attr = array(
359 'post_title' => $title,
360 'post_status' => $status,
361 'post_content' => $title,
362 'post_type' => 'prad_option',
363 );
364 $message = 'publish' === $status
365 ? __( 'Option set updated & published.', 'product-addons' )
366 : __( 'Option set updated & saved as a draft.', 'product-addons' );
367 if ( 'new' === $id ) {
368 $id = wp_insert_post( $attr );
369 if ( is_wp_error( $id ) ) {
370 return new WP_REST_Response(
371 array(
372 'success' => false,
373 'message' => __( 'Failed to create a new option.', 'product-addons' ),
374 ),
375 400
376 );
377 }
378
379 update_option( 'prad_first_option_created', 'yes' );
380 update_post_meta( $id, 'prad_addons_blocks', $content );
381 $required_options = $required_fields ? $required_fields : array();
382 update_post_meta( $id, 'prad_required_options', $required_options );
383 if ( $css ) {
384 update_post_meta( $id, 'prad_addons_css', $css );
385 }
386 do_action( 'prad_handle_cache_on_save' );
387
388 return new WP_REST_Response(
389 array(
390 'success' => true,
391 'message' => $message,
392 'id' => $id,
393 ),
394 200
395 );
396 } else {
397 $attr['ID'] = $id;
398 $update = wp_update_post( $attr, true );
399
400 if ( is_wp_error( $update ) ) {
401 return new WP_REST_Response(
402 array(
403 'success' => false,
404 'message' => __( 'Failed to update the option.', 'product-addons' ),
405 ),
406 400
407 );
408 }
409
410 if ( $css ) {
411 update_post_meta( $id, 'prad_addons_css', $css );
412 }
413
414 update_post_meta( $id, 'prad_addons_blocks', $content );
415 $required_options = $required_fields ? $required_fields : array();
416 update_post_meta( $id, 'prad_required_options', $required_options );
417 do_action( 'prad_handle_cache_on_save' );
418
419 return new WP_REST_Response(
420 array(
421 'success' => true,
422 'content' => $content,
423 'message' => $message,
424 ),
425 200
426 );
427 }
428 }
429
430 /**
431 * Updates the status of multiple options based on provided IDs.
432 *
433 * @since 1.0.0
434 *
435 * @param \WP_REST_Request $request The REST API request object.
436 * @return WP_REST_Response Response indicating success or failure of the update.
437 */
438 public function list_update_callback( \WP_REST_Request $request ) {
439 $params = $request->get_params();
440 $ids = isset( $params['ids'] ) ? sanitize_text_field( $params['ids'] ) : '';
441 $status = isset( $params['status'] ) ? sanitize_text_field( $params['status'] ) : '';
442 $nonce = isset( $params['wpnonce'] ) ? sanitize_text_field( $params['wpnonce'] ) : '';
443
444 if ( ! $nonce || ! wp_verify_nonce( $nonce, 'prad-nonce' ) ) {
445 return new WP_REST_Response(
446 array(
447 'success' => false,
448 'message' => __( 'Invalid or missing nonce.', 'product-addons' ),
449 ),
450 403
451 );
452 }
453
454 if ( empty( $ids ) ) {
455 return new WP_REST_Response(
456 array(
457 'success' => false,
458 'message' => __( 'No IDs provided.', 'product-addons' ),
459 ),
460 400
461 );
462 }
463
464 // Convert IDs to an array and update each post.
465 $ids_array = explode( ',', $ids );
466 foreach ( $ids_array as $id ) {
467 $attr = array(
468 'ID' => (int) $id,
469 'post_status' => ( 'active' === $status ) ? 'publish' : 'draft',
470 );
471
472 $update = wp_update_post( $attr, true );
473
474 if ( is_wp_error( $update ) ) {
475 return new WP_REST_Response(
476 array(
477 'success' => false,
478 'message' => sprintf(
479 /* translators: %1s - Post Id, %2s - Error Message */
480 __( 'Failed to update post ID: %1$s. Error: %2$s', 'product-addons' ),
481 $id,
482 $update->get_error_message()
483 ),
484 ),
485 400
486 );
487 }
488 }
489
490 do_action( 'prad_handle_cache_on_save' );
491 // Return success response.
492 return new WP_REST_Response(
493 array(
494 'success' => true,
495 'status' => $status,
496 'message' => __( 'Items updated successfully.', 'product-addons' ),
497 ),
498 200
499 );
500 }
501
502 /**
503 * Deletes multiple options based on provided IDs.
504 *
505 * @since 1.0.0
506 *
507 * @param \WP_REST_Request $request The REST API request object.
508 * @return WP_REST_Response Response indicating success or failure of the deletion.
509 */
510 public function list_delete_callback( \WP_REST_Request $request ) {
511 // Retrieve and sanitize request parameters.
512 $params = $request->get_params();
513 $ids = isset( $params['ids'] ) ? sanitize_text_field( $params['ids'] ) : '';
514 $nonce = isset( $params['wpnonce'] ) ? sanitize_text_field( $params['wpnonce'] ) : '';
515
516 if ( ! $nonce || ! wp_verify_nonce( $nonce, 'prad-nonce' ) ) {
517 return new WP_REST_Response(
518 array(
519 'success' => false,
520 'message' => __( 'Invalid or missing nonce.', 'product-addons' ),
521 ),
522 403
523 );
524 }
525
526 if ( empty( $ids ) ) {
527 return new WP_REST_Response(
528 array(
529 'success' => false,
530 'message' => __( 'No IDs provided.', 'product-addons' ),
531 ),
532 400
533 );
534 }
535
536 // Convert IDs to an array and delete each post.
537 $gallery_image_data = get_option( 'prad_product_image_update_data', array() );
538 $ids_array = explode( ',', $ids );
539 foreach ( $ids_array as $id ) {
540 $id = (int) $id; // Ensure ID is an integer.
541
542 if ( isset( $gallery_image_data[ $id ] ) ) {
543 unset( $gallery_image_data[ $id ] );
544 }
545
546 /**
547 * Fires before deleting a post with a specific ID.
548 *
549 * @param int $id The ID of the post to be deleted.
550 */
551 do_action( 'prad_delete_option_product_meta', $id );
552
553 wp_delete_post( $id, true );
554 }
555
556 update_option( 'prad_product_image_update_data', $gallery_image_data );
557
558 return new WP_REST_Response(
559 array(
560 'success' => true,
561 'message' => __( 'Items deleted successfully.', 'product-addons' ),
562 ),
563 200
564 );
565 }
566
567
568 /**
569 * Duplicates an option based on the provided ID.
570 *
571 * @since 1.0.0
572 *
573 * @param \WP_REST_Request $request The REST API request object.
574 * @return WP_REST_Response Response indicating success or failure of the duplication.
575 */
576 public function list_duplicate_callback( \WP_REST_Request $request ) {
577 // Retrieve and sanitize the request parameter.
578 $params = $request->get_params();
579 $id = isset( $params['id'] ) ? sanitize_text_field( $params['id'] ) : '';
580 $content = isset( $params['content'] ) && is_array( $params['content'] ) ? product_addons()->sanitize_rest_params( $params['content'] ) : array();
581 $nonce = isset( $params['wpnonce'] ) ? sanitize_text_field( $params['wpnonce'] ) : '';
582
583 if ( ! $nonce || ! wp_verify_nonce( $nonce, 'prad-nonce' ) ) {
584 return new WP_REST_Response(
585 array(
586 'success' => false,
587 'message' => __( 'Invalid or missing nonce.', 'product-addons' ),
588 ),
589 403
590 );
591 }
592
593 if ( empty( $id ) ) {
594 return new WP_REST_Response(
595 array(
596 'success' => false,
597 'message' => __( 'No ID provided.', 'product-addons' ),
598 ),
599 400
600 );
601 }
602
603 // Retrieve the post object and proceed with duplication.
604 $post = get_post( $id );
605 if ( ! $post ) {
606 return new WP_REST_Response(
607 array(
608 'success' => false,
609 'message' => __( 'Post not found.', 'product-addons' ),
610 ),
611 404
612 );
613 }
614
615 // Set up the arguments for duplicating the post.
616 $args = array(
617 'post_author' => $post->post_author,
618 'post_content' => $post->post_content,
619 'post_name' => $post->post_name,
620 'post_status' => 'draft',
621 'post_title' => $post->post_title . ' Copy',
622 'post_type' => $post->post_type,
623 );
624
625 // Insert the new post (duplicate).
626 $new_id = wp_insert_post( $args );
627
628 if ( is_wp_error( $new_id ) ) {
629 return new WP_REST_Response(
630 array(
631 'success' => false,
632 'message' => sprintf(
633 /* translators: %1s - Post Id */
634 __( 'Failed to duplicate post ID: %s.', 'product-addons' ),
635 $id
636 ),
637 ),
638 400
639 );
640 }
641
642 // Copy the custom meta data.
643 update_option( 'prad_first_option_created', 'yes' );
644 $blocks = $content ? $content : get_post_meta( $id, 'prad_addons_blocks', true );
645 update_post_meta( $new_id, 'prad_addons_blocks', $blocks );
646
647 // Return success response.
648 return new WP_REST_Response(
649 array(
650 'success' => true,
651 'message' => __( 'Item duplicated successfully.', 'product-addons' ),
652 'new_id' => $new_id,
653 ),
654 200
655 );
656 }
657 /**
658 * Import List
659 *
660 * @since 1.0.0
661 *
662 * @param \WP_REST_Request $request The REST API request object.
663 * @return WP_REST_Response Response indicating success or failure of the duplication.
664 */
665 public function list_import_callback( \WP_REST_Request $request ) {
666 $params = $request->get_params();
667 $title = isset( $params['title'] ) ? sanitize_text_field( $params['title'] ) : '';
668 $content = isset( $params['content'] ) && is_array( $params['content'] ) ? product_addons()->sanitize_rest_params( $params['content'] ) : array();
669 $nonce = isset( $params['wpnonce'] ) ? sanitize_text_field( $params['wpnonce'] ) : '';
670
671 if ( ! $nonce || ! wp_verify_nonce( $nonce, 'prad-nonce' ) ) {
672 return new WP_REST_Response(
673 array(
674 'success' => false,
675 'message' => __( 'Invalid or missing nonce.', 'product-addons' ),
676 ),
677 403
678 );
679 }
680
681 $args = array(
682 'post_status' => 'draft',
683 'post_title' => $title . ' Imported',
684 'post_type' => 'prad_option',
685 );
686
687 $new_id = wp_insert_post( $args );
688 update_option( 'prad_first_option_created', 'yes' );
689 update_post_meta( $new_id, 'prad_addons_blocks', $content );
690
691 return new WP_REST_Response(
692 array(
693 'success' => true,
694 'message' => __( 'Imported successfully.', 'product-addons' ),
695 'new_id' => $new_id,
696 ),
697 200
698 );
699 }
700
701 /**
702 * Retrieves a list of options with search and pagination functionality.
703 *
704 * @since 1.0.0
705 *
706 * @param \WP_REST_Request $request The REST API request object.
707 * @return WP_REST_Response Response containing the list of options and pagination info.
708 */
709 public function option_listing_callback( \WP_REST_Request $request ) {
710 $params = $request->get_params();
711 $search = isset( $params['search'] ) ? sanitize_text_field( $params['search'] ) : '';
712 $paged = isset( $params['page'] ) ? sanitize_text_field( $params['page'] ) : 1;
713 $per_page = isset( $params['per_page'] ) ? sanitize_text_field( $params['per_page'] ) : 3;
714 $order = isset( $params['order'] ) ? sanitize_text_field( $params['order'] ) : 'DESC';
715 $product_id = isset( $params['product_id'] ) ? absint( $params['product_id'] ) : 0;
716 $nonce = isset( $params['wpnonce'] ) ? sanitize_text_field( $params['wpnonce'] ) : '';
717
718 if ( ! $nonce || ! wp_verify_nonce( $nonce, 'prad-nonce' ) ) {
719 return new WP_REST_Response(
720 array(
721 'success' => false,
722 'message' => __( 'Invalid or missing nonce.', 'product-addons' ),
723 ),
724 403
725 );
726 }
727
728 $args = array(
729 'post_type' => 'prad_option',
730 'posts_per_page' => $per_page,
731 'order' => $order,
732 'orderby' => 'ID',
733 'post_status' => array( 'publish', 'draft' ),
734 'paged' => $paged,
735 );
736
737 if ( $product_id ) {
738 $args['post__in'] = product_addons()->get_product_option_ids( $product_id );
739 $args['post__in'] = ! empty( $args['post__in'] ) ? $args['post__in'] : array( 0 );
740 }
741
742 $id_search_filter = null;
743
744 if ( ! empty( $search ) ) {
745 if ( ctype_digit( $search ) ) {
746 $id_search_filter = function ( $where ) use ( $search ) {
747 global $wpdb;
748 return $where . $wpdb->prepare( " AND {$wpdb->posts}.ID LIKE %s", '%' . $wpdb->esc_like( $search ) . '%' );
749 };
750 add_filter( 'posts_where', $id_search_filter );
751 } else {
752 $args['s'] = $search;
753 }
754 }
755
756 $query = new \WP_Query( $args );
757
758 if ( $id_search_filter ) {
759 remove_filter( 'posts_where', $id_search_filter );
760 }
761 $data = array();
762 $all_blocks = array();
763 $page_num = 0;
764
765 if ( $query->have_posts() ) {
766 while ( $query->have_posts() ) {
767 $query->the_post();
768 $id = get_the_ID();
769 $blocks = get_post_meta( $id, 'prad_addons_blocks', true );
770 $all_blocks[ $id ] = $blocks;
771 $data[] = array(
772 'id' => $id,
773 'title' => get_the_title(),
774 'status' => get_post_status() === 'publish',
775 'options' => is_string( $blocks ) ? substr_count( $blocks, 'blockid' ) : substr_count( wp_json_encode( $blocks ), 'blockid' ),
776 'assigned' => product_addons()->get_assigned_product_data( $id ),
777 );
778 }
779 $page_num = $query->max_num_pages;
780
781 wp_reset_postdata();
782 }
783
784 return new WP_REST_Response(
785 array(
786 'success' => true,
787 'page' => $page_num,
788 'posts' => $data,
789 'all_blocks' => $all_blocks,
790 ),
791 200
792 );
793 }
794
795 /**
796 * Searches for products or categories based on the provided keyword and trigger type.
797 *
798 * @since 1.0.0
799 *
800 * @param \WP_REST_Request $request The REST API request object.
801 * @return WP_REST_Response Response containing the search results.
802 */
803 public function assign_search_callback( \WP_REST_Request $request ) {
804 $params = $request->get_params();
805 $trigger_type = isset( $params['type'] ) ? sanitize_text_field( $params['type'] ) : 'products';
806 $search_keyword = isset( $params['term'] ) ? sanitize_text_field( $params['term'] ) : '';
807 $limit = isset( $params['limit'] ) ? absint( $params['limit'] ) : 60;
808 $tax_type = isset( $params['tax_type'] ) ? sanitize_text_field( $params['tax_type'] ) : '';
809 $tax_term_ids_raw = isset( $params['tax_term_ids'] ) && is_array( $params['tax_term_ids'] ) ? $params['tax_term_ids'] : array();
810 $tax_term_ids = array_map( 'absint', $tax_term_ids_raw );
811 $taxonomy_map = array(
812 'cat' => 'product_cat',
813 'tag' => 'product_tag',
814 'brand' => 'product_brand',
815 );
816
817 $response_data = array();
818 switch ( $trigger_type ) {
819 case 'products':
820 $tax_filter = array();
821 if ( $tax_term_ids && isset( $taxonomy_map[ $tax_type ] ) ) {
822 $tax_filter = array(
823 'taxonomy' => $taxonomy_map[ $tax_type ],
824 'term_ids' => $tax_term_ids,
825 );
826 }
827 $response_data = product_addons()->get_searched_products( $search_keyword, false, $limit, array(), $tax_filter );
828 break;
829 case 'cat':
830 case 'tag':
831 case 'brand':
832 $response_data = product_addons()->get_searched_categories(
833 array(
834 'term' => $search_keyword,
835 'limit' => $limit,
836 'includes' => '',
837 'trigger_type' => $trigger_type,
838 )
839 );
840 break;
841 default:
842 break;
843 }
844
845 return new WP_REST_Response(
846 array(
847 'success' => true,
848 'data' => $response_data,
849 ),
850 200
851 );
852 }
853
854 /**
855 * Searches for products or categories based on the provided keyword and trigger type.
856 *
857 * @since 1.0.3
858 *
859 * @param \WP_REST_Request $request The REST API request object.
860 * @return WP_REST_Response Response containing the search results.
861 */
862 public function get_product_search_callback( \WP_REST_Request $request ) {
863 $params = $request->get_params();
864 $search_keyword = isset( $params['term'] ) ? sanitize_text_field( $params['term'] ) : '';
865 $limit = isset( $params['limit'] ) ? absint( $params['limit'] ) : 5;
866 $exclude_ids = isset( $params['excludes'] ) ? $params['excludes'] : array();
867
868 // Load the product data store.
869 $data_store = WC_Data_Store::load( 'product' );
870
871 $include_ids = array();
872 $limit = '5';
873 $ids = $data_store->search_products( $search_keyword, '', false, false, $limit, $include_ids, $exclude_ids );
874 $products = array();
875
876 foreach ( $ids as $product_id ) {
877 $product = wc_get_product( $product_id );
878
879 if ( $product ) {
880 $datas = array(
881 'id' => $product_id,
882 'url' => get_permalink( $product_id ),
883 'value' => rawurldecode( wp_strip_all_tags( $product->get_name() ) ),
884 'img' => wp_get_attachment_url( $product->get_image_id() ),
885 'isVariable' => $product->is_type( 'variable' ),
886 );
887
888 if ( $product->is_type( 'variable' ) ) {
889 $available_variations = $product->get_available_variations();
890 $variations_data = array();
891
892 foreach ( $available_variations as $variation_data ) {
893 $variation_id = $variation_data['variation_id'];
894 $variation = wc_get_product( $variation_id );
895
896 if ( $variation && $variation->is_purchasable() && $variation->is_in_stock() ) {
897 $variation_formatted = wc_get_formatted_variation( $variation, true, false, true );
898 $variations_data[] = array(
899 'id' => $variation_id,
900 'url' => get_permalink( $variation_id ),
901 'value' => rawurldecode( wp_strip_all_tags( $variation_formatted ? $variation->get_name() . ' - ' . $variation_formatted : $variation->get_name() ) ),
902 'img' => wp_get_attachment_url( $variation->get_image_id() ),
903 'attributes' => wc_get_product_variation_attributes( $variation_id ),
904 'regular' => $variation->get_regular_price( 'edit' ),
905 'sale' => $variation->get_sale_price( 'edit' ),
906 );
907 }
908 }
909 $datas['variation'] = $variations_data;
910 }
911 $products[] = $datas;
912 }
913 }
914
915 return $products;
916 }
917
918 /**
919 * Get product link callback - Returns the first available product link based on assignment data.
920 *
921 * @param \WP_REST_Request $request The REST API request object.
922 * @return \WP_REST_Response Response containing product link and success status.
923 */
924 public function get_product_link_callback( \WP_REST_Request $request ) {
925 $params = $request->get_params();
926 $assigned_data = isset( $params['assignedData'] ) && is_array( $params['assignedData'] ) ? product_addons()->sanitize_rest_params( $params['assignedData'] ) : array();
927 $option_id = isset( $params['optionId'] ) ? sanitize_text_field( $params['optionId'] ) : '';
928
929 // Validate assigned data.
930 if ( empty( $assigned_data ) || ! isset( $assigned_data['aType'] ) ) {
931 return new WP_REST_Response(
932 array(
933 'success' => false,
934 'published' => false,
935 'message' => __( 'Invalid assigned data.', 'product-addons' ),
936 ),
937 400
938 );
939 }
940 $is_published = false;
941 if ( $option_id && 'new' !== $option_id ) {
942 $is_published = 'publish' === get_post_status( $option_id );
943 }
944
945 $assign_type = $assigned_data['aType'];
946
947 $product_link = '';
948 if ( 'specific_product' !== $assign_type && $is_published ) {
949 $product_link = $this->get_first_product_link( $assigned_data );
950 }
951
952 return new WP_REST_Response(
953 array(
954 'success' => true,
955 'published' => $is_published,
956 'productLink' => $product_link,
957 ),
958 200
959 );
960 }
961
962 /**
963 * Get the first available product link based on assignment type.
964 *
965 * @param array $assigned_data Assignment data containing type and includes/excludes.
966 * @return string Product permalink or empty string if no product found.
967 */
968 private function get_first_product_link( $assigned_data ) {
969 $exclude_ids = $this->parse_exclude_ids( $assigned_data );
970
971 switch ( $assigned_data['aType'] ) {
972 case 'all_product':
973 return $this->get_first_simple_product_link( $exclude_ids );
974 case 'specific_category':
975 case 'specific_tag':
976 case 'specific_brand':
977 return $this->get_first_taxonomy_product_link( $assigned_data, $exclude_ids );
978
979 default:
980 return '';
981 }
982 }
983
984 /**
985 * Parse exclude IDs from assigned data, handling both new object format and legacy format.
986 *
987 * @param array $assigned_data Assignment data.
988 * @return array Array of exclude product IDs.
989 */
990 private function parse_exclude_ids( $assigned_data ) {
991 $exclude_ids = array();
992
993 if ( empty( $assigned_data['excludes'] ) || ! is_array( $assigned_data['excludes'] ) ) {
994 return $exclude_ids;
995 }
996
997 foreach ( $assigned_data['excludes'] as $exclude_item ) {
998 if ( is_array( $exclude_item ) && isset( $exclude_item['item_id'] ) ) {
999 $exclude_ids[] = absint( $exclude_item['item_id'] );
1000 } elseif ( is_numeric( $exclude_item ) ) {
1001 // Fallback for legacy format.
1002 $exclude_ids[] = absint( $exclude_item );
1003 }
1004 }
1005
1006 return array_filter( $exclude_ids );
1007 }
1008
1009 /**
1010 * Get the first simple product link, excluding specified products.
1011 *
1012 * @param array $exclude_ids Product IDs to exclude.
1013 * @return string Product permalink or empty string.
1014 */
1015 private function get_first_simple_product_link( $exclude_ids ) {
1016 $data_store = WC_Data_Store::load( 'product' );
1017 $product_ids = $data_store->search_products( '', '', true, false, 99, array(), $exclude_ids );
1018
1019 foreach ( $product_ids as $product_id ) {
1020 $product = wc_get_product( $product_id );
1021 if ( $product && $product->get_type() !== 'variation' && $product->get_status() === 'publish' ) {
1022 return get_permalink( $product_id );
1023 }
1024 }
1025
1026 return '';
1027 }
1028
1029 /**
1030 * Get the first product link from taxonomy terms.
1031 *
1032 * @param array $assigned_data Assignment data.
1033 * @param array $exclude_ids Product IDs to exclude.
1034 * @return string Product permalink or empty string.
1035 */
1036 private function get_first_taxonomy_product_link( $assigned_data, $exclude_ids ) {
1037 if ( empty( $assigned_data['includes'] ) || ! is_array( $assigned_data['includes'] ) ) {
1038 return '';
1039 }
1040
1041 $taxonomy_map = array(
1042 'specific_category' => 'product_cat',
1043 'specific_tag' => 'product_tag',
1044 'specific_brand' => 'product_brand',
1045 );
1046
1047 $taxonomy = $taxonomy_map[ $assigned_data['aType'] ] ?? '';
1048 if ( ! $taxonomy ) {
1049 return '';
1050 }
1051
1052 // Parse term IDs and get products in one optimized flow.
1053 $all_product_ids = array();
1054
1055 foreach ( $assigned_data['includes'] as $include_item ) {
1056 $term_id = 0;
1057 if ( is_array( $include_item ) && isset( $include_item['item_id'] ) ) {
1058 $term_id = absint( $include_item['item_id'] );
1059 } elseif ( is_numeric( $include_item ) ) {
1060 $term_id = absint( $include_item );
1061 }
1062
1063 if ( $term_id ) {
1064 $term_products = get_objects_in_term( $term_id, $taxonomy );
1065 if ( ! empty( $term_products ) ) {
1066 $all_product_ids = array_merge( $all_product_ids, $term_products );
1067 }
1068 }
1069 }
1070
1071 // Process and find first valid product.
1072 if ( ! empty( $all_product_ids ) ) {
1073 $unique_products = array_unique( array_map( 'absint', $all_product_ids ) );
1074 $filtered_products = ! empty( $exclude_ids ) ? array_diff( $unique_products, $exclude_ids ) : $unique_products;
1075
1076 foreach ( $filtered_products as $product_id ) {
1077 $product = wc_get_product( $product_id );
1078 if ( $product && $product->get_type() !== 'variation' && $product->get_status() === 'publish' ) {
1079 return get_permalink( $product_id );
1080 }
1081 }
1082 }
1083
1084 return '';
1085 }
1086
1087 /**
1088 * Searches for products or categories based on the provided keyword and trigger type.
1089 *
1090 * @since 1.0.3
1091 *
1092 * @param \WP_REST_Request $request The REST API request object.
1093 * @return WP_REST_Response Response containing the search results.
1094 */
1095 public function get_products_details_callback( \WP_REST_Request $request ) {
1096 $params = $request->get_params();
1097 $items = isset( $params['items'] ) && is_array( $params['items'] ) ? $params['items'] : array();
1098 $output = array();
1099
1100 foreach ( $items as $item ) {
1101 $product_id = isset( $item['id'] ) ? absint( $item['id'] ) : 0;
1102
1103 if ( ! $product_id ) {
1104 continue;
1105 }
1106
1107 $product = wc_get_product( $product_id );
1108
1109 if ( ! $product ) {
1110 continue;
1111 }
1112
1113 $formatted_variation = $product->is_type( 'variable' ) ? '' : wc_get_formatted_variation( $product, true, false, true );
1114 $product_value = $product->get_name() . ( $formatted_variation ? ' - ' . $formatted_variation : '' );
1115
1116 $data = array(
1117 'id' => $product_id,
1118 'editLink' => html_entity_decode( get_edit_post_link( $product_id ) ),
1119 'url' => get_permalink( $product_id ),
1120 'value' => rawurldecode( wp_strip_all_tags( $product_value ) ),
1121 'img' => wp_get_attachment_url( $product->get_image_id() ),
1122 'regular' => $product->get_regular_price( 'edit' ),
1123 'sale' => $product->get_sale_price( 'edit' ),
1124 );
1125
1126 if ( $product->is_type( 'variable' ) ) {
1127 $variation_ids_input = isset( $item['variation'] ) && is_array( $item['variation'] ) ? array_map( 'absint', $item['variation'] ) : array();
1128 $available_variations = $product->get_available_variations();
1129 $variations_data = array();
1130
1131 foreach ( $available_variations as $variation_data ) {
1132 $variation_id = $variation_data['variation_id'];
1133 $variation = wc_get_product( $variation_id );
1134
1135 if ( $variation && $variation->is_purchasable() && $variation->is_in_stock() ) {
1136 $variation_formatted = wc_get_formatted_variation( $variation, true, false, true );
1137 $variations_data[] = array(
1138 'id' => $variation_id,
1139 'url' => get_permalink( $variation_id ),
1140 'value' => rawurldecode( wp_strip_all_tags( $variation_formatted ? $variation->get_name() . ' - ' . $variation_formatted : $variation->get_name() ) ),
1141 'img' => wp_get_attachment_url( $variation->get_image_id() ),
1142 'attributes' => wc_get_product_variation_attributes( $variation_id ),
1143 'regular' => $variation->get_regular_price( 'edit' ),
1144 'sale' => $variation->get_sale_price( 'edit' ),
1145 'enable' => in_array( $variation_id, $variation_ids_input ),
1146 );
1147 }
1148 }
1149 $data['variation'] = $variations_data;
1150 }
1151
1152 $output[] = $data;
1153 }
1154
1155 return $output;
1156 }
1157
1158 /**
1159 * Retrieves assigned product data based on the provided option ID.
1160 *
1161 * @since 1.0.0
1162 *
1163 * @param \WP_REST_Request $request The REST request object containing the option ID.
1164 *
1165 * @return \WP_REST_Response The REST response containing the assigned data or an error message.
1166 */
1167 public function get_assign_product_callback( \WP_REST_Request $request ) {
1168 $request_params = $request->get_params();
1169 $option_id = ! empty( $request_params['option_id'] ) ? sanitize_text_field( $request_params['option_id'] ) : '';
1170
1171 if ( $option_id ) {
1172 return new WP_REST_Response(
1173 array(
1174 'success' => true,
1175 'assigned' => product_addons()->get_assigned_product_data( $option_id ),
1176 ),
1177 200
1178 );
1179 }
1180
1181 // If option_id is missing, return a message indicating the issue.
1182 return new WP_REST_Response(
1183 array(
1184 'success' => false,
1185 'message' => 'Option ID Missing',
1186 ),
1187 400
1188 );
1189 }
1190
1191
1192 /**
1193 * Set assigned product data for a given option.
1194 *
1195 * @since 1.0.0
1196 *
1197 * @param \WP_REST_Request $request The REST request object containing the option ID and assignment data.
1198 *
1199 * @return \WP_REST_Response The REST response containing the success message or an error response.
1200 */
1201 public function set_assign_product_callback( \WP_REST_Request $request ) {
1202 $params = $request->get_params();
1203 $option_id = ! empty( $params['option_id'] ) ? sanitize_text_field( $params['option_id'] ) : '';
1204 $product_image = ! empty( $params['product_image'] ) ? product_addons()->sanitize_rest_params( $params['product_image'] ) : array();
1205 $nonce = isset( $params['wpnonce'] ) ? sanitize_text_field( $params['wpnonce'] ) : '';
1206
1207 if ( ! $nonce || ! wp_verify_nonce( $nonce, 'prad-nonce' ) ) {
1208 return new WP_REST_Response(
1209 array(
1210 'success' => false,
1211 'message' => __( 'Invalid or missing nonce.', 'product-addons' ),
1212 ),
1213 403
1214 );
1215 }
1216
1217 if ( empty( $option_id ) ) {
1218 return new WP_REST_Response(
1219 array(
1220 'success' => false,
1221 'response' => array(
1222 'message' => __( 'No ID found', 'product-addons' ),
1223 ),
1224 ),
1225 400
1226 );
1227 }
1228
1229 $new_image_data = get_option( 'prad_product_image_update_data', array() );
1230 $new_image_data[ $option_id ] = $product_image;
1231 update_option( 'prad_product_image_update_data', $new_image_data );
1232
1233 $raw_data = isset( $params['raw_data'] ) ? product_addons()->sanitize_rest_params( $params['raw_data'] ) : array();
1234
1235 /* First Remove existing assign include / excludes meta */
1236 $this->handle_existing_assign_meta( $option_id );
1237
1238 // Update Option Data Both Option Data and Product/Term Data.
1239 if ( 'specific_product' === $raw_data['aType'] ) { // Update meta for Specific Product.
1240 if ( is_array( $raw_data['includes'] ) && ! empty( $raw_data['includes'] ) ) {
1241 foreach ( $raw_data['includes'] as $include ) {
1242 $meta_inc = json_decode( product_addons()->safe_stripslashes( get_post_meta( $include, 'prad_product_assigned_meta_inc', true ) ), true );
1243 $meta_inc = is_array( $meta_inc ) ? $meta_inc : array();
1244
1245 if ( ! in_array( $option_id, $meta_inc, false ) ) {
1246 $meta_inc[] = $option_id;
1247 }
1248 update_post_meta( $include, 'prad_product_assigned_meta_inc', wp_json_encode( $meta_inc ) );
1249 }
1250 }
1251 } elseif ( 'specific_category' === $raw_data['aType'] || 'specific_tag' === $raw_data['aType'] || 'specific_brand' === $raw_data['aType'] ) { /* Update meta for Terms */
1252 if ( is_array( $raw_data['includes'] ) && ! empty( $raw_data['includes'] ) ) {
1253 foreach ( $raw_data['includes'] as $include ) {
1254 $meta_inc = json_decode( product_addons()->safe_stripslashes( get_term_meta( $include, 'prad_term_assigned_meta_inc', true ) ), true );
1255 $meta_inc = is_array( $meta_inc ) ? $meta_inc : array();
1256
1257 if ( ! in_array( $option_id, $meta_inc, false ) ) {
1258 $meta_inc[] = $option_id;
1259 }
1260 update_term_meta( $include, 'prad_term_assigned_meta_inc', wp_json_encode( $meta_inc ) );
1261 }
1262 }
1263 } elseif ( 'all_product' === $raw_data['aType'] ) { // Update meta for All Products.
1264 $option_settings = json_decode( product_addons()->safe_stripslashes( get_option( 'prad_option_assign_all', '[]' ) ), true );
1265 $option_settings = is_array( $option_settings ) ? $option_settings : array();
1266
1267 if ( ! in_array( $option_id, $option_settings, false ) ) {
1268 $option_settings[] = $option_id;
1269 }
1270 update_option( 'prad_option_assign_all', wp_json_encode( $option_settings ) );
1271 }
1272
1273 // Update Meta for Excludes Products.
1274 if ( is_array( $raw_data['excludes'] ) && count( $raw_data['excludes'] ) > 0 ) {
1275 foreach ( $raw_data['excludes'] as $exclude ) {
1276 $meta_exc = json_decode( product_addons()->safe_stripslashes( get_post_meta( $exclude, 'prad_product_assigned_meta_exc', true ) ), true );
1277 $meta_exc = is_array( $meta_exc ) ? $meta_exc : array();
1278
1279 if ( ! in_array( $option_id, $meta_exc, false ) ) {
1280 $meta_exc[] = $option_id;
1281 }
1282 update_post_meta( $exclude, 'prad_product_assigned_meta_exc', wp_json_encode( $meta_exc ) );
1283 }
1284 }
1285
1286 // Update Meta for Exclude Categories.
1287 if ( isset( $raw_data['excludeCategories'] ) && is_array( $raw_data['excludeCategories'] ) && count( $raw_data['excludeCategories'] ) > 0 ) {
1288 foreach ( $raw_data['excludeCategories'] as $exclude_cat ) {
1289 $meta_exc_cat = json_decode( product_addons()->safe_stripslashes( get_term_meta( $exclude_cat, 'prad_term_assigned_meta_exc', true ) ), true );
1290 $meta_exc_cat = is_array( $meta_exc_cat ) ? $meta_exc_cat : array();
1291
1292 if ( ! in_array( $option_id, $meta_exc_cat, false ) ) {
1293 $meta_exc_cat[] = $option_id;
1294 }
1295 update_term_meta( $exclude_cat, 'prad_term_assigned_meta_exc', wp_json_encode( $meta_exc_cat ) );
1296 }
1297 }
1298
1299 // Update the option meta with the assigned data.
1300 update_post_meta( $option_id, 'prad_base_assigned_data', wp_json_encode( $raw_data ) );
1301
1302 return new WP_REST_Response(
1303 array(
1304 'success' => true,
1305 'response' => array(
1306 'product_image' => $product_image,
1307 'option_id' => $option_id,
1308 'message' => __( 'Option Assigned Updated successfully', 'product-addons' ),
1309 'newData' => json_decode( product_addons()->safe_stripslashes( get_post_meta( $option_id, 'prad_base_assigned_data', true ) ), true ),
1310 ),
1311 ),
1312 200
1313 );
1314 }
1315
1316 /**
1317 * Handle Existing Assign Data
1318 *
1319 * @since 1.0.0
1320 * @param int $option_id The ID of the option to retrieve assigned data for.
1321 *
1322 * @return void
1323 */
1324 public function handle_existing_assign_meta( $option_id ) {
1325 $assigned_data = json_decode( product_addons()->safe_stripslashes( get_post_meta( $option_id, 'prad_base_assigned_data', true ) ), true );
1326 if ( isset( $assigned_data['aType'] ) && 'all_product' === $assigned_data['aType'] ) {
1327 $option_settings = json_decode( product_addons()->safe_stripslashes( get_option( 'prad_option_assign_all', '[]' ) ), true );
1328 $option_settings = is_array( $option_settings ) ? $option_settings : array();
1329
1330 if ( in_array( $option_id, $option_settings, false ) ) {
1331 $option_settings = array_diff( $option_settings, array( $option_id ) );
1332 }
1333 update_option( 'prad_option_assign_all', wp_json_encode( $option_settings ) );
1334 } elseif ( isset( $assigned_data['aType'] ) && 'specific_product' === $assigned_data['aType'] ) {
1335 if ( is_array( $assigned_data['includes'] ) && ! empty( $assigned_data['includes'] ) ) {
1336 foreach ( $assigned_data['includes'] as $include ) {
1337 $meta_inc = json_decode( product_addons()->safe_stripslashes( get_post_meta( $include, 'prad_product_assigned_meta_inc', true ) ), true );
1338 $meta_inc = is_array( $meta_inc ) ? $meta_inc : array();
1339
1340 if ( in_array( $option_id, $meta_inc, false ) ) {
1341 $meta_inc = array_diff( $meta_inc, array( $option_id ) );
1342 }
1343 update_post_meta( $include, 'prad_product_assigned_meta_inc', wp_json_encode( $meta_inc ) );
1344 }
1345 }
1346 } elseif ( isset( $assigned_data['aType'] ) && ( 'specific_category' === $assigned_data['aType'] || 'specific_tag' === $assigned_data['aType'] || 'specific_brand' === $assigned_data['aType'] ) ) {
1347 if ( is_array( $assigned_data['includes'] ) && ! empty( $assigned_data['includes'] ) ) {
1348 foreach ( $assigned_data['includes'] as $include ) {
1349 $meta_inc = json_decode( product_addons()->safe_stripslashes( get_term_meta( $include, 'prad_term_assigned_meta_inc', true ) ), true );
1350 $meta_inc = is_array( $meta_inc ) ? $meta_inc : array();
1351
1352 if ( in_array( $option_id, $meta_inc, false ) ) {
1353 $meta_inc = array_diff( $meta_inc, array( $option_id ) );
1354 }
1355 update_term_meta( $include, 'prad_term_assigned_meta_inc', wp_json_encode( $meta_inc ) );
1356 }
1357 }
1358 }
1359 if ( isset( $assigned_data['excludes'] ) && is_array( $assigned_data['excludes'] ) && count( $assigned_data['excludes'] ) > 0 ) {
1360 foreach ( $assigned_data['excludes'] as $exclude ) {
1361 $meta_exc = json_decode( product_addons()->safe_stripslashes( get_post_meta( $exclude, 'prad_product_assigned_meta_exc', true ) ), true );
1362 $meta_exc = is_array( $meta_exc ) ? $meta_exc : array();
1363
1364 if ( in_array( $option_id, $meta_exc, false ) ) {
1365 $meta_exc = array_diff( $meta_exc, array( $option_id ) );
1366 }
1367 update_post_meta( $exclude, 'prad_product_assigned_meta_exc', wp_json_encode( $meta_exc ) );
1368 }
1369 }
1370 if ( isset( $assigned_data['excludeCategories'] ) && is_array( $assigned_data['excludeCategories'] ) && count( $assigned_data['excludeCategories'] ) > 0 ) {
1371 foreach ( $assigned_data['excludeCategories'] as $exclude_cat ) {
1372 $meta_exc_cat = json_decode( product_addons()->safe_stripslashes( get_term_meta( $exclude_cat, 'prad_term_assigned_meta_exc', true ) ), true );
1373 $meta_exc_cat = is_array( $meta_exc_cat ) ? $meta_exc_cat : array();
1374
1375 if ( in_array( $option_id, $meta_exc_cat, false ) ) {
1376 $meta_exc_cat = array_diff( $meta_exc_cat, array( $option_id ) );
1377 }
1378 update_term_meta( $exclude_cat, 'prad_term_assigned_meta_exc', wp_json_encode( $meta_exc_cat ) );
1379 }
1380 }
1381 }
1382
1383 /**
1384 * Get global data.
1385 *
1386 * @since 1.0.0
1387 *
1388 * @return \WP_REST_Response The REST response containing the global data.
1389 */
1390 public function get_global_callback() {
1391 return new WP_REST_Response(
1392 array(
1393 'success' => true,
1394 'response' => array(
1395 'globalStyle' => get_option( 'prad_global_style', '' ),
1396 'thematicStyle' => get_option( 'prad_global_style_thematic', '' ),
1397 ),
1398 ),
1399 200
1400 );
1401 }
1402 /**
1403 * Set global data.
1404 *
1405 * @since 1.0.0
1406 *
1407 * @param \WP_REST_Request $request The request object containing the data.
1408 *
1409 * @return \WP_REST_Response The REST response with success or error message.
1410 */
1411 public function set_global_callback( \WP_REST_Request $request ) {
1412 $request_params = $request->get_params();
1413 $style = isset( $request_params['style'] ) ? product_addons()->sanitize_rest_params( $request_params['style'] ) : '';
1414 $css = isset( $request_params['css'] ) ? sanitize_textarea_field( $request_params['css'] ) : '';
1415 $is_themetic = isset( $request_params['isThemetic'] ) ? sanitize_textarea_field( $request_params['isThemetic'] ) : 'no';
1416 $nonce = isset( $request_params['wpnonce'] ) ? sanitize_text_field( $request_params['wpnonce'] ) : '';
1417
1418 if ( ! $nonce || ! wp_verify_nonce( $nonce, 'prad-nonce' ) ) {
1419 return new WP_REST_Response(
1420 array(
1421 'success' => false,
1422 'message' => __( 'Invalid or missing nonce.', 'product-addons' ),
1423 ),
1424 403
1425 );
1426 }
1427
1428 if ( 'yes' === $is_themetic ) {
1429 if ( $style ) {
1430 update_option( 'prad_global_style_thematic', $style );
1431 }
1432 if ( $css ) {
1433 update_option( 'prad_global_style_thematic_css', $css );
1434 }
1435 } else {
1436 if ( $style ) {
1437 update_option( 'prad_global_style', $style );
1438 }
1439 if ( $css ) {
1440 update_option( 'prad_global_style_css', $css );
1441 }
1442 }
1443
1444 return new WP_REST_Response(
1445 array(
1446 'success' => true,
1447 'message' => __( 'Style saved successfully.', 'product-addons' ),
1448 ),
1449 200
1450 );
1451 }
1452
1453 /**
1454 * Get global data.
1455 *
1456 * @since 1.0.0
1457 *
1458 * @return \WP_REST_Response The REST response containing the global data.
1459 */
1460 public function get_settings_callback() {
1461 return new WP_REST_Response(
1462 array(
1463 'success' => true,
1464 'response' => get_option( 'prad_settings', '' ),
1465 ),
1466 200
1467 );
1468 }
1469 /**
1470 * Set global data.
1471 *
1472 * @since 1.0.0
1473 *
1474 * @param \WP_REST_Request $request The request object containing the data.
1475 *
1476 * @return \WP_REST_Response The REST response with success or error message.
1477 */
1478 public function set_settings_callback( \WP_REST_Request $request ) {
1479 $request_params = $request->get_params();
1480 $settings = isset( $request_params['settings'] ) ? product_addons()->sanitize_rest_params( $request_params['settings'] ) : '';
1481 $nonce = isset( $request_params['wpnonce'] ) ? sanitize_text_field( $request_params['wpnonce'] ) : '';
1482
1483 if ( ! $nonce || ! wp_verify_nonce( $nonce, 'prad-nonce' ) ) {
1484 return new WP_REST_Response(
1485 array(
1486 'success' => false,
1487 'message' => __( 'Invalid or missing nonce.', 'product-addons' ),
1488 ),
1489 403
1490 );
1491 }
1492
1493 if ( $settings ) {
1494 update_option( 'prad_settings', $settings );
1495 }
1496
1497 return new WP_REST_Response(
1498 array(
1499 'success' => true,
1500 'message' => __( 'Settings saved successfully.', 'product-addons' ),
1501 ),
1502 200
1503 );
1504 }
1505
1506 /**
1507 * Upload image from URL and sideload it to the media library.
1508 *
1509 * @since 1.0.5
1510 *
1511 * @param \WP_REST_Request $request The request object containing the data.
1512 *
1513 * @return \WP_REST_Response The REST response with success or error message.
1514 */
1515 public function sideload_image_callback( \WP_REST_Request $request ) {
1516 $request_params = $request->get_params();
1517 $nonce = isset( $request_params['wpnonce'] ) ? sanitize_text_field( $request_params['wpnonce'] ) : '';
1518
1519 if ( ! $nonce || ! wp_verify_nonce( $nonce, 'prad-nonce' ) ) {
1520 return new WP_REST_Response(
1521 array(
1522 'success' => false,
1523 'message' => __( 'Invalid or missing nonce.', 'product-addons' ),
1524 ),
1525 403
1526 );
1527 }
1528
1529 $images = isset( $request_params['images'] ) && is_array( $request_params['images'] ) ? $request_params['images'] : array();
1530
1531 if ( empty( $images ) ) {
1532 return new WP_REST_Response(
1533 array(
1534 'success' => false,
1535 'message' => 'No images provided.',
1536 ),
1537 400
1538 );
1539 }
1540
1541 require_once ABSPATH . 'wp-admin/includes/media.php';
1542 require_once ABSPATH . 'wp-admin/includes/file.php';
1543 require_once ABSPATH . 'wp-admin/includes/image.php';
1544
1545 $results = array();
1546
1547 foreach ( $images as $raw_url => $raw_desc ) {
1548 $url = esc_url_raw( $raw_url );
1549 if ( ! $url ) {
1550 $results[ $raw_url ] = array( 'success' => false );
1551 continue;
1552 }
1553
1554 $desc = sanitize_text_field( $raw_desc );
1555 $attachment_id = media_sideload_image( $url, 0, $desc, 'id' );
1556 if ( is_wp_error( $attachment_id ) ) {
1557 $results[ $raw_url ] = array(
1558 'success' => false,
1559 'message' => $attachment_id->get_error_message(),
1560 );
1561 } else {
1562 $results[ $raw_url ] = array(
1563 'success' => true,
1564 'source_url' => wp_get_attachment_url( $attachment_id ),
1565 'id' => $attachment_id,
1566 );
1567 }
1568 }
1569
1570 return new WP_REST_Response(
1571 array(
1572 'success' => true,
1573 'results' => $results,
1574 ),
1575 200
1576 );
1577 }
1578
1579 /**
1580 * Product Image Compability
1581 *
1582 * @since 1.0.5
1583 *
1584 * @param \WP_REST_Request $request The request object containing the data.
1585 *
1586 * @return \WP_REST_Response The REST response with success or error message.
1587 */
1588 public function product_image_callback( \WP_REST_Request $request ) {
1589 $request_params = $request->get_params();
1590 $product_data = isset( $request_params['productData'] ) && is_array( $request_params['productData'] ) ? product_addons()->sanitize_rest_params( $request_params['productData'] ) : array();
1591
1592 $to_return = array();
1593 if ( ! empty( $product_data ) && is_array( $product_data ) ) {
1594 foreach ( $product_data as $key => $value ) {
1595 $id = function_exists( 'attachment_url_to_postid' ) && $value['src'] ? attachment_url_to_postid( $value['src'] ) : '';
1596 $to_return[ $key ] = $id;
1597 }
1598 }
1599
1600 return new WP_REST_Response(
1601 array(
1602 'success' => true,
1603 'message' => $product_data,
1604 'to_return' => $to_return,
1605 ),
1606 200
1607 );
1608 }
1609
1610 /**
1611 * Allowed file extensions and MIME types.
1612 *
1613 * @param array $mimes Existing allowed MIME types.
1614 * @return array
1615 */
1616 public function prad_handle_upload_field_mimes( $mimes ) {
1617 $prad_mimes = product_addons()->prad_get_upload_allowed_file_types( $this->extra_upload_field_mimes );
1618
1619 return array_merge( $mimes, $prad_mimes );
1620 }
1621
1622 /**
1623 * Retrieve and validate uploaded file.
1624 *
1625 * @return array|WP_Error
1626 */
1627 protected function get_uploaded_file() {
1628
1629 // Nonce verification before processing form data.
1630 $nonce = isset( $_POST['pradnonce'] ) ? sanitize_key( wp_unslash( $_POST['pradnonce'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
1631 if ( ! wp_verify_nonce( $nonce, 'prad-nonce' ) ) {
1632 return new \WP_Error( 'invalid_nonce', __( 'Invalid nonce.', 'product-addons' ) );
1633 }
1634
1635 if ( empty( $_FILES['prad_file'] ) ||
1636 empty( $_FILES['prad_file']['name'] )
1637 ) {
1638 return new \WP_Error( 'no_file', __( 'No file found.', 'product-addons' ) );
1639 }
1640
1641 $file = $_FILES['prad_file']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1642
1643 $max_file_size = 25 * 1024 * 1024; // 25MB
1644
1645 if ( (int) $file['size'] > $max_file_size ) {
1646 return new \WP_Error(
1647 'file_size',
1648 __( 'File size exceeds the maximum allowed limit (25MB).', 'product-addons' )
1649 );
1650 }
1651 if ( 'cdr' === strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) ) ) {
1652 $finfo = finfo_open( FILEINFO_MIME_TYPE );
1653 $mime = finfo_file( $finfo, $file['tmp_name'] );
1654 finfo_close( $finfo );
1655
1656 if ( 'application/x-vnd.corel.zcf.draw.document+zip' === $mime ) {
1657 $this->extra_upload_field_mimes = array(
1658 'cdr' => 'application/x-vnd.corel.zcf.draw.document+zip',
1659 );
1660 }
1661 }
1662
1663 add_filter( 'upload_mimes', array( $this, 'prad_handle_upload_field_mimes' ) );
1664
1665 $allowed_types = product_addons()->prad_get_upload_allowed_file_types( $this->extra_upload_field_mimes );
1666
1667 $filetype = wp_check_filetype_and_ext(
1668 $file['tmp_name'],
1669 $file['name'],
1670 $allowed_types
1671 );
1672
1673 if ( empty( $filetype['ext'] ) || empty( $filetype['type'] ) ) {
1674 return new \WP_Error(
1675 'invalid_type',
1676 __( 'Invalid file type.', 'product-addons' )
1677 );
1678 }
1679
1680 return $file;
1681 }
1682
1683
1684 /**
1685 * Handles file uploads via REST API.
1686 *
1687 * @since 1.0.0
1688 *
1689 * @return WP_REST_Response Response indicating success or failure of the upload.
1690 */
1691 public function upload_files_callback() {
1692
1693 $nonce = isset( $_POST['pradnonce'] ) ? sanitize_key( wp_unslash( $_POST['pradnonce'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification
1694 if ( ! wp_verify_nonce( $nonce, 'prad-nonce' ) ) {
1695 return new WP_REST_Response(
1696 array(
1697 'success' => false,
1698 'message' => __( 'Invalid nonce.', 'product-addons' ),
1699 ),
1700 403
1701 );
1702 }
1703
1704 if ( ! $this->prad_check_upload_rate_limit() ) {
1705 return new WP_REST_Response(
1706 array(
1707 'success' => false,
1708 'message' => __( 'Upload limit exceeded. Please try again later.', 'product-addons' ),
1709 ),
1710 429
1711 );
1712 }
1713
1714 $file = $this->get_uploaded_file();
1715
1716 if ( is_wp_error( $file ) ) {
1717 remove_filter( 'upload_mimes', array( $this, 'prad_handle_upload_field_mimes' ) );
1718 return new WP_REST_Response(
1719 array(
1720 'success' => false,
1721 'message' => $file->get_error_message(),
1722 ),
1723 400
1724 );
1725 }
1726
1727 if ( ! function_exists( 'wp_handle_upload' ) ) {
1728 require_once ABSPATH . 'wp-admin/includes/file.php';
1729 }
1730
1731 // Validate and sanitize SVG content on the temp file before uploading.
1732 $filetype = wp_check_filetype( $file['name'] );
1733 if ( 'image/svg+xml' === $filetype['type'] ) {
1734 if ( ! $this->prad_sanitize_svg_file( $file['tmp_name'] ) ) {
1735 remove_filter( 'upload_mimes', array( $this, 'prad_handle_upload_field_mimes' ) );
1736 return new WP_REST_Response(
1737 array(
1738 'success' => false,
1739 'message' => __( 'SVG file could not be processed. Please check the file and try again.', 'product-addons' ),
1740 ),
1741 400
1742 );
1743 }
1744 }
1745
1746 add_filter( 'upload_dir', array( $this, 'prad_handle_upload_dir' ) );
1747
1748 $uploaded = wp_handle_upload(
1749 $file,
1750 array(
1751 'test_form' => false,
1752 )
1753 );
1754
1755 remove_filter( 'upload_dir', array( $this, 'prad_handle_upload_dir' ) );
1756 remove_filter( 'upload_mimes', array( $this, 'prad_handle_upload_field_mimes' ) );
1757
1758 if ( isset( $uploaded['error'] ) ) {
1759 return new WP_REST_Response(
1760 array(
1761 'success' => false,
1762 'message' => $uploaded['error'],
1763 ),
1764 400
1765 );
1766 }
1767
1768 $this->prad_ensure_upload_dir_security( dirname( $uploaded['file'] ) );
1769
1770 return new WP_REST_Response(
1771 array(
1772 'success' => true,
1773 'data' => array(
1774 'file' => $uploaded,
1775 ),
1776 ),
1777 200
1778 );
1779 }
1780
1781 /**
1782 * Enforce a per-IP upload rate limit using a fixed hourly window.
1783 *
1784 * @return bool True if the request is within the allowed limit, false otherwise.
1785 */
1786 private function prad_check_upload_rate_limit() {
1787 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
1788 $hour = gmdate( 'YmdH' );
1789 $key = 'prad_upload_' . md5( $ip . $hour );
1790
1791 $count = (int) get_transient( $key );
1792 if ( $count >= 20 ) {
1793 return false;
1794 }
1795
1796 set_transient( $key, $count + 1, HOUR_IN_SECONDS );
1797 return true;
1798 }
1799
1800 /**
1801 * Sanitize an uploaded SVG file in-place, removing all executable content.
1802 *
1803 * Parses the SVG as XML, strips dangerous elements (script, foreignObject, …),
1804 * event-handler attributes (on*), javascript:/vbscript: URIs, and inline CSS
1805 * expressions, then writes the cleaned document back to disk.
1806 *
1807 * @param string $file_path Absolute path to the uploaded SVG file.
1808 * @return bool True on success, false if the file could not be parsed or written.
1809 */
1810 private function prad_sanitize_svg_file( $file_path ) {
1811 $content = @file_get_contents( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
1812
1813 if ( false === $content ) {
1814 return false;
1815 }
1816
1817 $dom = new \DOMDocument();
1818 $dom->formatOutput = false;
1819 $dom->preserveWhiteSpace = true;
1820
1821 // Prevent XXE on PHP < 8.0 (libxml 2.9+ disables external entities by default).
1822 $prev_loader = null;
1823
1824 if ( version_compare( PHP_VERSION, '8.0.0', '<' ) && function_exists( 'libxml_disable_entity_loader' ) ) {
1825 $prev_loader = libxml_disable_entity_loader( true ); // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions
1826 }
1827
1828 $loaded = @$dom->loadXML( $content, 2048 | 32 | 64 ); // LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING
1829
1830 if ( null !== $prev_loader && function_exists( 'libxml_disable_entity_loader' ) ) {
1831 libxml_disable_entity_loader( $prev_loader ); // phpcs:ignore PHPCompatibility.FunctionUse.RemovedFunctions
1832 }
1833
1834 if ( ! $loaded ) {
1835 @unlink( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
1836 return false;
1837 }
1838
1839 // Reject files that are not SVGs.
1840 $root = $dom->documentElement;
1841 if ( ! $root || 'svg' !== strtolower( $root->localName ) ) {
1842 @unlink( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
1843 return false;
1844 }
1845
1846 // Remove elements that can embed or execute code.
1847 $blocked_tags = array(
1848 'script',
1849 'foreignObject',
1850 'foreignobject',
1851 'iframe',
1852 'object',
1853 'embed',
1854 'video',
1855 'audio',
1856 'frame',
1857 'frameset',
1858 'applet',
1859 'animate',
1860 'animateMotion',
1861 'animatemotion',
1862 'animateTransform',
1863 'animatetransform',
1864 'set',
1865 );
1866
1867 foreach ( $blocked_tags as $tag ) {
1868 $nodes = $dom->getElementsByTagName( $tag );
1869
1870 for ( $i = $nodes->length - 1; $i >= 0; $i-- ) {
1871 $node = $nodes->item( $i );
1872
1873 if ( $node && $node->parentNode ) {
1874 $node->parentNode->removeChild( $node );
1875 }
1876 }
1877 }
1878
1879 // Remove processing instructions (e.g. xml-stylesheet declarations).
1880 $xpath = new \DOMXPath( $dom );
1881 $pis = $xpath->query( '//processing-instruction()' );
1882
1883 if ( $pis ) {
1884 for ( $i = $pis->length - 1; $i >= 0; $i-- ) {
1885 $pi = $pis->item( $i );
1886
1887 if ( $pi && $pi->parentNode ) {
1888 $pi->parentNode->removeChild( $pi );
1889 }
1890 }
1891 }
1892
1893 // URL-bearing attributes that may carry javascript: or external URLs.
1894 $url_attrs = array(
1895 'href',
1896 'xlink:href',
1897 'src',
1898 'action',
1899 'formaction',
1900 'data',
1901 'poster',
1902 'dynsrc',
1903 'lowsrc',
1904 );
1905
1906 $all_nodes = $dom->getElementsByTagName( '*' );
1907
1908 for ( $i = 0; $i < $all_nodes->length; $i++ ) {
1909 $el = $all_nodes->item( $i );
1910
1911 if ( ! ( $el instanceof \DOMElement ) ) {
1912 continue;
1913 }
1914
1915 $tag_name = strtolower( $el->localName );
1916 $remove_attrs = array();
1917 $attributes = array();
1918
1919 foreach ( $el->attributes as $attr ) {
1920 $attributes[] = $attr;
1921 }
1922
1923 foreach ( $attributes as $attr ) {
1924 $node_local_name = $attr->localName; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
1925 $node_ns_uri = $attr->namespaceURI; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
1926 $node_qname = $attr->name;
1927
1928 // Use the local name (namespace prefix stripped) for matching so
1929 // namespaced attributes like xlink:href are not missed, but keep
1930 // the namespace URI so removal can target the correct node.
1931 $attr_name = strtolower( $node_local_name ? $node_local_name : $node_qname );
1932 $attr_value = $attr->value;
1933
1934 $mark_for_removal = static function () use ( &$remove_attrs, $node_qname, $node_ns_uri, $attr_name ) {
1935 $remove_attrs[] = array(
1936 'name' => $node_qname,
1937 'ns' => $node_ns_uri,
1938 'local' => $attr_name,
1939 );
1940 };
1941
1942 // Remove all event-handler attributes (onclick, onload, onerror, etc.).
1943 if ( 0 === strpos( $attr_name, 'on' ) ) {
1944 $mark_for_removal();
1945 continue;
1946 }
1947
1948 if ( in_array( $attr_name, $url_attrs, true ) ) {
1949 // Collapse whitespace to catch tab/newline encoded variants.
1950 $normalized = strtolower(
1951 preg_replace( '/[\x00-\x20]+/', '', $attr_value )
1952 );
1953
1954 if ( preg_match( '/^(javascript|vbscript|data):/i', $normalized ) ) {
1955 $mark_for_removal();
1956 continue;
1957 }
1958
1959 // <use> elements must only reference same-document fragments (#id).
1960 if ( 'use' === $tag_name && '#' !== substr( ltrim( $attr_value ), 0, 1 ) ) {
1961 $mark_for_removal();
1962 continue;
1963 }
1964 }
1965
1966 // Remove style attributes containing javascript: or CSS expression().
1967 if ( 'style' === $attr_name ) {
1968 $clean = preg_replace( '/\/\*.*?\*\//s', '', $attr_value );
1969
1970 if (
1971 preg_match( '/javascript:/i', $clean ) ||
1972 preg_match( '/expression\s*\(/i', $clean )
1973 ) {
1974 $mark_for_removal();
1975 continue;
1976 }
1977 }
1978
1979 // xml:base can redirect relative references to an attacker-controlled URL.
1980 if ( 'base' === $attr_name && 'http://www.w3.org/XML/1998/namespace' === $node_ns_uri ) {
1981 $mark_for_removal();
1982 }
1983 }
1984
1985 foreach ( $remove_attrs as $target ) {
1986 if ( $target['ns'] ) {
1987 $el->removeAttributeNS( $target['ns'], $target['local'] );
1988 }
1989
1990 // Also remove any non-namespaced attribute sharing the qualified name.
1991 $el->removeAttribute( $target['name'] );
1992 }
1993 }
1994
1995 // Strip dangerous constructs from inline <style> blocks.
1996 $style_nodes = $dom->getElementsByTagName( 'style' );
1997
1998 for ( $i = 0; $i < $style_nodes->length; $i++ ) {
1999 $style_node = $style_nodes->item( $i );
2000
2001 if ( ! $style_node ) {
2002 continue;
2003 }
2004
2005 $css = $style_node->textContent;
2006
2007 $css = preg_replace( '/@import\b[^;]*;?/i', '', $css );
2008 $css = preg_replace( '/url\s*\(\s*["\']?\s*javascript:[^)]*\)/i', 'url()', $css );
2009 $css = preg_replace( '/expression\s*\([^)]*\)/i', 'none', $css );
2010 $css = preg_replace( '/\bbehavior\s*:[^;]+;?/i', '', $css );
2011
2012 while ( $style_node->firstChild ) {
2013 $style_node->removeChild( $style_node->firstChild );
2014 }
2015
2016 $style_node->appendChild( $dom->createTextNode( $css ) );
2017 }
2018
2019 $sanitized = $dom->saveXML( $dom->documentElement );
2020
2021 if ( false === $sanitized ) {
2022 @unlink( $file_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions
2023 return false;
2024 }
2025
2026 $result = file_put_contents( $file_path, $sanitized ); // phpcs:ignore WordPress.WP.AlternativeFunctions
2027
2028 return false !== $result;
2029 }
2030
2031 /**
2032 * Write a .htaccess to the upload directory that forces SVGs to download
2033 * rather than render inline, preventing stored XSS via SVG files.
2034 *
2035 * Only creates the file if it does not already exist; safe to call on every upload.
2036 *
2037 * @param string $dir_path Absolute filesystem path to the upload directory.
2038 */
2039 private function prad_ensure_upload_dir_security( $dir_path ) {
2040 $htaccess = trailingslashit( $dir_path ) . '.htaccess';
2041 if ( file_exists( $htaccess ) ) {
2042 return;
2043 }
2044
2045 $rules = "<IfModule mod_headers.c>\n";
2046 $rules .= " <FilesMatch \"\\.svgz?$\">\n";
2047 $rules .= " Header set Content-Disposition \"attachment\"\n";
2048 $rules .= " Header set X-Content-Type-Options \"nosniff\"\n";
2049 $rules .= " Header set Content-Security-Policy \"default-src 'none'\"\n";
2050 $rules .= " </FilesMatch>\n";
2051 $rules .= " Header set X-Content-Type-Options \"nosniff\"\n";
2052 $rules .= "</IfModule>\n";
2053
2054 file_put_contents( $htaccess, $rules ); // phpcs:ignore WordPress.WP.AlternativeFunctions
2055 }
2056
2057 /**
2058 * Customize the upload directory path for PRAD files.
2059 *
2060 * @param array $upload The existing upload directory data.
2061 * @return array The modified upload directory data.
2062 */
2063 public function prad_handle_upload_dir( $upload ) {
2064 $directory = 'prad_option_files/temp';
2065 $upload['subdir'] = '/' . $directory;
2066 $upload['path'] = $upload['basedir'] . $upload['subdir'];
2067 $upload['url'] = $upload['baseurl'] . $upload['subdir'];
2068 return $upload;
2069 }
2070
2071 /**
2072 * Set analytics data callback.
2073 *
2074 * Handles updating analytics data for a given option ID and type.
2075 *
2076 * @since 1.0.0
2077 *
2078 * @param \WP_REST_Request $request The REST API request object.
2079 * @return WP_REST_Response Response indicating success or failure.
2080 */
2081 public function set_analytics_data_callback( \WP_REST_Request $request ) {
2082 $request_params = $request->get_params();
2083 $nonce = isset( $request_params['nonce'] ) ? sanitize_key( $request_params['nonce'] ) : '';
2084 if ( ! wp_verify_nonce( $nonce, 'prad-nonce' ) ) {
2085 return new WP_REST_Response(
2086 array(
2087 'success' => false,
2088 'message' => __( 'Invalid nonce.', 'product-addons' ),
2089 ),
2090 403
2091 );
2092 }
2093 $option_id = isset( $request_params['optionId'] ) ? sanitize_text_field( $request_params['optionId'] ) : '';
2094 $type = isset( $request_params['type'] ) ? sanitize_text_field( $request_params['type'] ) : '';
2095
2096 if ( $option_id && $type ) {
2097 do_action( 'prad_update_stats_table_data', $option_id, $type, '' );
2098 return new WP_REST_Response(
2099 array(
2100 'success' => true,
2101 'option_id' => $option_id,
2102 'type' => $type,
2103 'message' => __( 'Analytics data updated.', 'product-addons' ),
2104 ),
2105 200
2106 );
2107 }
2108 return new WP_REST_Response(
2109 array(
2110 'success' => false,
2111 'message' => __( 'Invalid Analytics data.', 'product-addons' ),
2112 ),
2113 400
2114 );
2115 }
2116
2117 /**
2118 * Retrieves a list of options with search and pagination functionality.
2119 *
2120 * @since 1.0.0
2121 *
2122 * @param \WP_REST_Request $request The REST API request object.
2123 * @return WP_REST_Response Response containing the list of options and pagination info.
2124 */
2125 public function get_analytics_data_callback( \WP_REST_Request $request ) {
2126 $params = $request->get_params();
2127 $search = isset( $params['search'] ) ? sanitize_text_field( $params['search'] ) : '';
2128
2129 global $wpdb;
2130
2131 $table_name = $wpdb->prefix . 'prad_stats_graph';
2132 $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ); // phpcs:ignore
2133 if ( $table_exists ) {
2134 $stats_graph = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM `%1$s` ORDER BY id ASC', $table_name ) ); // phpcs:ignore
2135 } else {
2136 $wpdb->hide_errors();
2137 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
2138 $analytics = new Analytics();
2139 $analytics->create_stats_graph_table();
2140 $stats_graph = array();
2141 }
2142
2143 return new WP_REST_Response(
2144 array(
2145 'success' => true,
2146 'stats_table' => $this->stats_table_data(),
2147 'stats_graph' => ! empty( $stats_graph ) ? $stats_graph : array(),
2148 ),
2149 200
2150 );
2151 }
2152
2153 /**
2154 * Retrieves analytics stats table data for options.
2155 *
2156 * @since 1.0.0
2157 *
2158 * @return array Stats table data for options.
2159 */
2160 public function stats_table_data() {
2161 global $wpdb;
2162
2163 $table_name = $wpdb->prefix . 'prad_stats_table';
2164 $table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) ); // phpcs:ignore
2165 if ( ! $table_exists ) {
2166 $wpdb->hide_errors();
2167 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
2168 $analytics = new Analytics();
2169 $analytics->create_stats_table();
2170 return array();
2171 }
2172
2173 $paged = 1;
2174 $per_page = -1;
2175 $order = 'DESC';
2176
2177 $args = array(
2178 'post_type' => 'prad_option',
2179 'posts_per_page' => $per_page,
2180 'order' => $order,
2181 'orderby' => 'ID',
2182 'post_status' => array( 'publish', 'draft' ),
2183 'paged' => $paged,
2184 );
2185
2186 $query = new \WP_Query( $args );
2187 $data = array();
2188
2189 if ( $query->have_posts() ) {
2190 while ( $query->have_posts() ) {
2191 $query->the_post();
2192 $id = get_the_ID();
2193 $option_stats = $wpdb->get_results(//phpcs:ignore
2194 $wpdb->prepare(
2195 "SELECT * FROM {$wpdb->prefix}prad_stats_table WHERE option_id = %d",
2196 $id
2197 )
2198 );
2199
2200 if ( ! empty( $option_stats ) ) {
2201 $option_stats = $option_stats[0];
2202 } else {
2203 $option_stats = (object) array();
2204 }
2205
2206 $click_rate = ( isset( $option_stats->impression_count ) && isset( $option_stats->click_count ) && $option_stats->impression_count > 0 ) ? ( $option_stats->click_count / $option_stats->impression_count ) * 100 : 0;
2207 $cart_rate = ( isset( $option_stats->impression_count ) && isset( $option_stats->add_to_cart_count ) && $option_stats->impression_count > 0 ) ? ( $option_stats->add_to_cart_count / $option_stats->impression_count ) * 100 : 0;
2208
2209 $data[] = array(
2210 'id' => $id,
2211 'title' => get_the_title(),
2212 'click' => round( $click_rate ),
2213 'cart' => round( $cart_rate ),
2214 'sales' => isset( $option_stats->sales ) ? $option_stats->sales : 0,
2215 'option_stats' => $option_stats,
2216 'assigned' => product_addons()->get_assigned_product_data( $id ),
2217 );
2218 }
2219
2220 wp_reset_postdata();
2221 }
2222
2223 return $data;
2224 }
2225
2226 /**
2227 * Customize the upload directory path for font files.
2228 *
2229 * @param array $upload The existing upload directory data.
2230 * @return array The modified upload directory data.
2231 */
2232 public function prad_handle_font_upload_dir( $upload ) {
2233 $directory = 'prad_font_files';
2234 $upload['subdir'] = '/' . $directory;
2235 $upload['path'] = $upload['basedir'] . $upload['subdir'];
2236 $upload['url'] = $upload['baseurl'] . $upload['subdir'];
2237 return $upload;
2238 }
2239
2240 /**
2241 * Upload Font File Callback
2242 *
2243 * @since 1.0.0
2244 *
2245 * @param \WP_REST_Request $request The REST API request object.
2246 * @return WP_REST_Response Response indicating success or failure.
2247 */
2248 public function upload_font_callback( \WP_REST_Request $request ) {
2249 // Verify nonce and file.
2250 if (
2251 empty( $_FILES['font_file'] ) ||
2252 ! isset( $_FILES['font_file']['name'] ) ||
2253 ( ! ( isset( $_POST['pradnonce'] ) && wp_verify_nonce( sanitize_key( wp_unslash( $_POST['pradnonce'] ) ), 'prad-nonce' ) ) )
2254 ) {
2255 return new WP_REST_Response(
2256 array(
2257 'success' => false,
2258 'message' => __( 'No file found or invalid nonce.', 'product-addons' ),
2259 ),
2260 400
2261 );
2262 }
2263
2264 $params = $request->get_params();
2265 $font_title = isset( $params['font_title'] ) ? sanitize_text_field( $params['font_title'] ) : '';
2266 $font_family = isset( $params['font_family'] ) ? sanitize_text_field( $params['font_family'] ) : '';
2267
2268 if ( empty( $font_title ) ) {
2269 return new WP_REST_Response(
2270 array(
2271 'success' => false,
2272 'message' => __( 'Font title is required.', 'product-addons' ),
2273 ),
2274 400
2275 );
2276 }
2277
2278 $uploaded_file = $_FILES['font_file']; // phpcs:ignore
2279
2280 // Check if the wp_handle_upload function exists.
2281 if ( ! function_exists( 'wp_handle_upload' ) ) {
2282 require_once ABSPATH . 'wp-admin/includes/file.php';
2283 }
2284
2285 // Allowed font file types.
2286 $allowed_extensions = array( 'woff', 'woff2', 'ttf' );
2287 $allowed_mime_types = array(
2288 'font/woff',
2289 'font/woff2',
2290 'application/x-font-woff',
2291 'application/font-woff',
2292 'application/x-font-ttf',
2293 'application/x-font-truetype',
2294 'font/ttf',
2295 );
2296 $max_file_size = 10 * 1024 * 1024; // 10MB
2297 $file_error = '';
2298
2299 // Validate file size.
2300 if ( $uploaded_file['size'] > $max_file_size ) {
2301 $file_error = __( 'File size exceeds the maximum allowed limit (10MB).', 'product-addons' );
2302 }
2303
2304 // Validate file extension.
2305 $file_extension = strtolower( pathinfo( $uploaded_file['name'], PATHINFO_EXTENSION ) );
2306 if ( ! in_array( $file_extension, $allowed_extensions, true ) ) {
2307 $file_error = __( 'Invalid file extension. Allowed types are: woff, woff2, ttf.', 'product-addons' );
2308 }
2309
2310 // Return error response if validation fails.
2311 if ( $file_error ) {
2312 return new WP_REST_Response(
2313 array(
2314 'success' => false,
2315 'message' => $file_error,
2316 ),
2317 400
2318 );
2319 }
2320
2321 // Add custom upload directory filter.
2322 add_filter( 'upload_dir', array( $this, 'prad_handle_font_upload_dir' ) );
2323
2324 // Check if file with same name exists and rename if necessary.
2325 $upload_dir = wp_upload_dir();
2326 $target_dir = $upload_dir['basedir'] . '/prad_font_files';
2327
2328 if ( ! file_exists( $target_dir ) ) {
2329 wp_mkdir_p( $target_dir );
2330 }
2331
2332 $original_filename = $uploaded_file['name'];
2333 $filename = basename( $original_filename );
2334 $target_file = $target_dir . '/' . $filename;
2335 $counter = 1;
2336
2337 // Rename if file exists.
2338 while ( file_exists( $target_file ) ) {
2339 $file_info = pathinfo( $original_filename );
2340 $filename = $file_info['filename'] . '-' . $counter . '.' . $file_info['extension'];
2341 $target_file = $target_dir . '/' . $filename;
2342 ++$counter;
2343 }
2344
2345 $uploaded_file['name'] = $filename;
2346
2347 $upload_overrides = array(
2348 'test_form' => false,
2349 'test_type' => false,
2350 'mimes' => array(
2351 'woff' => 'font/woff|application/font-woff|application/x-font-woff',
2352 'woff2' => 'font/woff2',
2353 'ttf' => 'font/ttf|application/x-font-ttf|application/x-font-truetype',
2354 ),
2355 );
2356
2357 // Handle the file upload.
2358 $uploaded = wp_handle_upload( $uploaded_file, $upload_overrides ); // Remove the custom upload directory filter after processing.
2359 remove_filter( 'upload_dir', array( $this, 'prad_handle_font_upload_dir' ) );
2360
2361 if ( isset( $uploaded['error'] ) ) {
2362 return new WP_REST_Response(
2363 array(
2364 'success' => false,
2365 'message' => $uploaded['error'],
2366 ),
2367 400
2368 );
2369 }
2370
2371 // Save font data to options.
2372 $fonts = get_option( 'prad_custom_fonts', array() );
2373
2374 if ( ! is_array( $fonts ) ) {
2375 $fonts = array();
2376 }
2377
2378 // Generate font face name if not provided.
2379 if ( empty( $font_family ) ) {
2380 $font_family = sanitize_title( $font_title );
2381 }
2382
2383 $font_data = array(
2384 'id' => uniqid( 'font_' ),
2385 'title' => $font_title,
2386 'src' => $uploaded['url'],
2387 'family' => $font_family,
2388 'file_type' => $file_extension,
2389 );
2390
2391 $fonts[] = $font_data;
2392 update_option( 'prad_custom_fonts', $fonts );
2393
2394 return new WP_REST_Response(
2395 array(
2396 'success' => true,
2397 'message' => __( 'Font uploaded successfully.', 'product-addons' ),
2398 'data' => $font_data,
2399 ),
2400 200
2401 );
2402 }
2403
2404 /**
2405 * Get Fonts List Callback
2406 *
2407 * @since 1.0.0
2408 *
2409 * @return WP_REST_Response Response containing the fonts list.
2410 */
2411 public function get_fonts_callback() {
2412 $fonts = get_option( 'prad_custom_fonts', array() );
2413
2414 if ( ! is_array( $fonts ) ) {
2415 $fonts = array();
2416 }
2417
2418 return new WP_REST_Response(
2419 array(
2420 'success' => true,
2421 'data' => $fonts,
2422 ),
2423 200
2424 );
2425 }
2426
2427 /**
2428 * Delete Font Callback
2429 *
2430 * @since 1.0.0
2431 *
2432 * @param \WP_REST_Request $request The REST API request object.
2433 * @return WP_REST_Response Response indicating success or failure.
2434 */
2435 public function delete_font_callback( \WP_REST_Request $request ) {
2436 $params = $request->get_params();
2437 $font_id = isset( $params['font_id'] ) ? sanitize_text_field( $params['font_id'] ) : '';
2438 $nonce = isset( $params['wpnonce'] ) ? sanitize_text_field( $params['wpnonce'] ) : '';
2439
2440 if ( ! $nonce || ! wp_verify_nonce( $nonce, 'prad-nonce' ) ) {
2441 return new WP_REST_Response(
2442 array(
2443 'success' => false,
2444 'message' => __( 'Invalid or missing nonce.', 'product-addons' ),
2445 ),
2446 403
2447 );
2448 }
2449
2450 if ( empty( $font_id ) ) {
2451 return new WP_REST_Response(
2452 array(
2453 'success' => false,
2454 'message' => __( 'Font ID is required.', 'product-addons' ),
2455 ),
2456 400
2457 );
2458 }
2459
2460 $fonts = get_option( 'prad_custom_fonts', array() );
2461 $font_found = false;
2462 $file_path = '';
2463
2464 if ( is_array( $fonts ) ) {
2465 foreach ( $fonts as $key => $font ) {
2466 if ( $font['id'] === $font_id ) {
2467 $font_found = true;
2468 $file_path = str_replace( wp_upload_dir()['baseurl'], wp_upload_dir()['basedir'], $font['src'] );
2469
2470 // Delete the physical file.
2471 if ( file_exists( $file_path ) ) {
2472 wp_delete_file( $file_path );
2473 }
2474
2475 // Remove from array.
2476 unset( $fonts[ $key ] );
2477 break;
2478 }
2479 }
2480
2481 // Reindex array.
2482 $fonts = array_values( $fonts );
2483 update_option( 'prad_custom_fonts', $fonts );
2484 }
2485
2486 if ( ! $font_found ) {
2487 return new WP_REST_Response(
2488 array(
2489 'success' => false,
2490 'message' => __( 'Font not found.', 'product-addons' ),
2491 ),
2492 404
2493 );
2494 }
2495
2496 return new WP_REST_Response(
2497 array(
2498 'success' => true,
2499 'message' => __( 'Font deleted successfully.', 'product-addons' ),
2500 ),
2501 200
2502 );
2503 }
2504
2505 /**
2506 * Update Font Callback
2507 *
2508 * @since 1.0.0
2509 *
2510 * @param \WP_REST_Request $request The REST API request object.
2511 * @return WP_REST_Response Response indicating success or failure.
2512 */
2513 public function update_font_callback( \WP_REST_Request $request ) {
2514 $params = $request->get_params();
2515 $font_id = isset( $params['font_id'] ) ? sanitize_text_field( $params['font_id'] ) : '';
2516 $font_title = isset( $params['font_title'] ) ? sanitize_text_field( $params['font_title'] ) : '';
2517 $font_family = isset( $params['font_family'] ) ? sanitize_text_field( $params['font_family'] ) : '';
2518 $nonce = isset( $params['wpnonce'] ) ? sanitize_text_field( $params['wpnonce'] ) : '';
2519
2520 if ( ! $nonce || ! wp_verify_nonce( $nonce, 'prad-nonce' ) ) {
2521 return new WP_REST_Response(
2522 array(
2523 'success' => false,
2524 'message' => __( 'Invalid or missing nonce.', 'product-addons' ),
2525 ),
2526 403
2527 );
2528 }
2529
2530 if ( empty( $font_id ) ) {
2531 return new WP_REST_Response(
2532 array(
2533 'success' => false,
2534 'message' => __( 'Font ID is required.', 'product-addons' ),
2535 ),
2536 400
2537 );
2538 }
2539
2540 if ( empty( $font_title ) ) {
2541 return new WP_REST_Response(
2542 array(
2543 'success' => false,
2544 'message' => __( 'Font title is required.', 'product-addons' ),
2545 ),
2546 400
2547 );
2548 }
2549
2550 $fonts = get_option( 'prad_custom_fonts', array() );
2551 $font_found = false;
2552
2553 if ( is_array( $fonts ) ) {
2554 foreach ( $fonts as $key => $font ) {
2555 if ( $font['id'] === $font_id ) {
2556 $font_found = true;
2557 $fonts[ $key ]['title'] = $font_title;
2558 $fonts[ $key ]['family'] = $font_family ? $font_family : $font_title;
2559 break;
2560 }
2561 }
2562
2563 update_option( 'prad_custom_fonts', $fonts );
2564 }
2565
2566 if ( ! $font_found ) {
2567 return new WP_REST_Response(
2568 array(
2569 'success' => false,
2570 'message' => __( 'Font not found.', 'product-addons' ),
2571 ),
2572 404
2573 );
2574 }
2575
2576 return new WP_REST_Response(
2577 array(
2578 'success' => true,
2579 'message' => __( 'Font updated successfully.', 'product-addons' ),
2580 ),
2581 200
2582 );
2583 }
2584
2585 /**
2586 * Dismiss the builder onboarding tour.
2587 *
2588 * Sets the same flag that creating a first option list sets, so the site
2589 * stops counting as a fresh install. Skipping the tour never creates an
2590 * add-on, so without this the tour would reappear on every page load.
2591 *
2592 * @since v.1.6.17
2593 *
2594 * @return WP_REST_Response
2595 */
2596 public function dismiss_tour_callback() {
2597 update_option( 'prad_first_option_created', 'yes' );
2598
2599 return new WP_REST_Response(
2600 array(
2601 'success' => true,
2602 ),
2603 200
2604 );
2605 }
2606
2607 public function durbin_subscribe_callback( \WP_REST_Request $request ) {
2608 if ( ! wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ) ) {
2609 return new WP_REST_Response( array( 'message' => 'Invalid nonce.' ), 403 );
2610 }
2611
2612 DurbinClient::send( DurbinClient::WIZARD_ACTION );
2613
2614 return new WP_REST_Response( array( 'sent' => true ), 200 );
2615 }
2616 }
2617