PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.6.6
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.6.6
2.6.6 2.6.5 2.6.4 2.6.3 2.6.2 2.6.1 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5.0 2.4.0 2.3.3 2.3.2 2.3.1 1.0.10 1.0.11 1.0.12 1.0.13 1.0.14 1.0.15 1.0.16 1.0.17 All 128 releases
← All changes | includes/admin/class-api.php +1495 -183 2.3.32.6.6 View file →
@@ -135,9 +135,84 @@
135 135 wp_send_json_success( $data, $status );
136 136 wp_die();
137 137 }
138 138
139 +
139 140 /**
141 + * Memory headroom left for image work, in bytes. 0 means unlimited.
142 + */
143 + private static function wdkit_available_image_memory() {
144 + $limit = wp_convert_hr_to_bytes( ini_get( 'memory_limit' ) );
145 +
146 + if ( $limit <= 0 ) {
147 + return 0;
148 + }
149 +
150 + return max( 0, $limit - memory_get_usage( true ) );
151 + }
152 +
153 + /**
154 + * Stop WordPress decoding images that cannot fit in the memory available.
155 + *
156 + * Both filters are consulted by wp_create_image_subsizes() *before* it loads an image
157 + * editor, so refusing here means the oversized image is never decoded:
158 + *
159 + * big_image_size_threshold -> falsy skips the "-scaled" copy (needs a full decode)
160 + * intermediate_image_sizes_advanced -> empty makes _wp_make_subsizes() return early,
161 + * ahead of its wp_get_image_editor() call
162 + *
163 + * The original file is still attached and usable; only the derived sizes are skipped.
164 + * That trades ideal thumbnails for an import that completes, instead of a fatal that
165 + * takes the whole page down and repeats on every retry.
166 + *
167 + * @since 2.6.2
168 + */
169 + private static function wdkit_guard_oversized_images() {
170 + static $registered = false;
171 +
172 + // Registering twice would stack duplicate closures on both filters.
173 + if ( $registered ) {
174 + return;
175 + }
176 +
177 + $registered = true;
178 +
179 + if ( ! class_exists( 'Wdkit_Image_Guard' ) ) {
180 + require_once WDKIT_INCLUDES . 'admin/class-wdkit-image-guard.php';
181 + }
182 +
183 + add_filter(
184 + 'big_image_size_threshold',
185 + function ( $threshold, $imagesize = array(), $file = '', $attachment_id = 0 ) {
186 + if ( ! empty( $imagesize[0] ) && ! empty( $imagesize[1] )
187 + && ! Wdkit_Image_Guard::decode_fits( $imagesize[0], $imagesize[1], self::wdkit_available_image_memory() )
188 + ) {
189 + return false;
190 + }
191 +
192 + return $threshold;
193 + },
194 + 99,
195 + 4
196 + );
197 +
198 + add_filter(
199 + 'intermediate_image_sizes_advanced',
200 + function ( $sizes, $image_meta = array(), $attachment_id = 0 ) {
201 + if ( ! empty( $image_meta['width'] ) && ! empty( $image_meta['height'] )
202 + && ! Wdkit_Image_Guard::decode_fits( $image_meta['width'], $image_meta['height'], self::wdkit_available_image_memory() )
203 + ) {
204 + return array();
205 + }
206 +
207 + return $sizes;
208 + },
209 + 99,
210 + 3
211 + );
212 + }
213 +
214 + /**
140 215 * Get Wdkit Api Call Ajax.
141 216 */
142 217 public function wdkit_api_call() {
143 218
@@ -236,8 +311,11 @@
236 311 break;
237 312 case 'generate_ai_content':
238 313 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'generate_ai_content' );
239 314 break;
315 + case 'generate_ai_content_batch':
316 + $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'generate_ai_content_batch' );
317 + break;
240 318 case 'reset_site':
241 319 $data = apply_filters( 'wp_wdkit_import_temp_ajax', 'reset_site' );
242 320 break;
243 321 case 'wdkit_nxt_thembuilder_reset':
@@ -296,8 +374,11 @@
296 374 break;
297 375 case 'wkit_update_elementor_template':
298 376 $data = $this->wkit_update_elementor_template();
299 377 break;
378 + case 'wdkit_update_page_content':
379 + $data = $this->wdkit_update_page_content();
380 + break;
300 381 case 'update_plugin_setting':
301 382 $data = $this->update_plugin_setting();
302 383 break;
303 384 case 'update_theme_setting':
@@ -330,9 +411,9 @@
330 411
331 412 wp_send_json(
332 413 array(
333 414 'success' => false,
334 - 'message' => 'No block names received or filter not found.',
415 + 'message' => __( 'No block names received or filter not found.', 'wdesignkit' ),
335 416 'description' => 'Ensure blockNames are posted and the filter is attached.',
336 417 )
337 418 );
338 419 wp_die();
@@ -364,8 +445,11 @@
364 445 break;
365 446 case 'wkit_check_widget_versions':
366 447 $data = apply_filters( 'wp_wdkit_widget_ajax', 'wkit_check_widget_versions' );
367 448 break;
449 + case 'wkit_plugin_download_get':
450 + $data = apply_filters( 'wp_wdkit_widget_ajax', 'wkit_plugin_download_get' );
451 + break;
368 452 case 'wkit_manage_widget_workspace':
369 453 $data = $this->wdkit_manage_widget_workspace();
370 454 break;
371 455 case 'wkit_activate_key':
@@ -424,8 +508,11 @@
424 508 break;
425 509 case 'wdkit_get_workspace_data':
426 510 $data = $this->wdkit_get_workspace_data();
427 511 break;
512 + default:
513 + $this->wdkit_error_msg( __( 'Unknown request type.', 'wdesignkit' ) );
514 + return;
428 515 }
429 516
430 517 $this->wdkit_success_msg( $data );
431 518 // wp_die();
@@ -436,12 +523,13 @@
436 523 * This Function is used for API call
437 524 *
438 525 * @since 1.0.0
439 526 *
440 - * @param array $data give array.
441 - * @param array $name store data.
527 + * @param array $data give array.
528 + * @param array $name store data.
529 + * @param int $timeout optional HTTP timeout in seconds. Default 100.
442 530 */
443 - protected function wkit_api_call( $data, $name ) {
531 + protected function wkit_api_call( $data, $name, $timeout = 100 ) {
444 532 $u_r_l = $this->wdkit_api;
445 533
446 534 if ( empty( $u_r_l ) ) {
447 535 return array(
@@ -452,9 +540,9 @@
452 540
453 541 $args = array(
454 542 'method' => 'POST',
455 543 'body' => $data,
456 - 'timeout' => 100,
544 + 'timeout' => $timeout,
457 545 );
458 546 $response = wp_remote_post( $u_r_l . $name, $args );
459 547
460 548 if ( is_wp_error( $response ) ) {
@@ -460,9 +548,9 @@
460 548 if ( is_wp_error( $response ) ) {
461 549 $error_message = $response->get_error_message();
462 550
463 551 /* Translators: %s is a placeholder for the error message */
464 - $error_message = printf( esc_html__( 'API request error: %s', 'wdesignkit' ), esc_html( $error_message ) );
552 + $error_message = sprintf( esc_html__( 'API request error: %s', 'wdesignkit' ), esc_html( $error_message ) );
465 553
466 554 return array(
467 555 'massage' => $error_message,
468 556 'success' => false,
@@ -479,9 +567,9 @@
479 567 'success' => true,
480 568 );
481 569 }
482 570
483 - $error_message = printf( 'Server error: %d', esc_html( $status_code ) );
571 + $error_message = sprintf( 'Server error: %d', esc_html( $status_code ) );
484 572
485 573 if ( isset( $error_data->message ) ) {
486 574 $error_message .= ' (' . $error_data->message . ')';
487 575 }
@@ -678,15 +766,22 @@
678 766 $nexter_active_check = is_plugin_active( 'the-plus-addons-for-block-editor/the-plus-addons-for-block-editor.php' );
679 767
680 768 $theplus_licence = get_option( 'tpaep_licence_data', array() );
681 769
682 - if ( ! empty( $theplus_active_check ) && ! empty( $theplus_licence ) ) {
770 + // Also require the TPAE Pro plugin to be active (Pro defines THEPLUS_VERSION;
771 + // the free plugin defines L_THEPLUS_VERSION). This hides the "found active
772 + // key" notice when the Pro plugin is removed even though its licence option
773 + // still lingers in the database.
774 + if ( ! empty( $theplus_active_check ) && defined( 'THEPLUS_VERSION' ) && ! empty( $theplus_licence ) ) {
683 775 $manage_licence['tpae'] = $theplus_licence;
684 776 }
685 777
686 778 $nexter_licence = get_option( 'tpgb_activate', array() );
687 779
688 - if ( ! empty( $nexter_active_check ) && ! empty( $nexter_licence ) && ! empty( $nexter_licence['tpgb_activate_key'] ) ) {
780 + // Also require the Nexter Blocks Pro plugin to be active (Pro defines
781 + // TPGBP_VERSION; the free plugin defines TPGB_VERSION), so the notice hides
782 + // when the Pro plugin is removed but its licence option persists.
783 + if ( ! empty( $nexter_active_check ) && defined( 'TPGBP_VERSION' ) && ! empty( $nexter_licence ) && ! empty( $nexter_licence['tpgb_activate_key'] ) ) {
689 784 $tpgb_license_status = get_option( 'tpgbp_license_status', array() );
690 785 $tpgb_license_status['license_key'] = $nexter_licence['tpgb_activate_key'];
691 786 $manage_licence['tpag'] = $tpgb_license_status;
692 787 }
@@ -727,14 +822,24 @@
727 822 'site_url' => $site_url,
728 823 );
729 824
730 825 $response = WDesignKit_Data_Query::get_data( 'get_user_info', $args );
826 +
827 + if ( is_wp_error( $response ) ) {
828 + wp_send_json( array(
829 + 'success' => false,
830 + 'message' => $response->get_error_message(),
831 + 'description' => $response->get_error_message(),
832 + ) );
833 + wp_die();
834 + }
835 +
731 836 $status = ( ! empty( $response['status'] ) ) ? sanitize_text_field( $response['status'] ) : 'error';
732 837 $email = isset( $_POST['email'] ) ? strtolower( sanitize_email( wp_unslash( $_POST['email'] ) ) ) : false;
733 838
734 839 /**Condtion user for user logout & expire token*/
735 840 if ( 'Token is Expired' === $status || 'Authorization Token not found' === $status ) {
736 - delete_transient( 'wdkit_auth_' . $email );
841 + delete_transient( 'wdkit_auth_' . wdesignkit_cloud_session_key( $email ) );
737 842 // Clear stored license data when token expires so banner shows again
738 843 delete_option( 'wdkit_licence_data' );
739 844 }
740 845
@@ -746,9 +851,9 @@
746 851 if ( ! empty( $response['credits']['wdkit_licence'] ) && is_array( $response['credits']['wdkit_licence'] ) ) {
747 852 $wdkit_licence = $response['credits']['wdkit_licence'];
748 853 // Handle serialized data
749 854 if ( is_string( $wdkit_licence ) && is_serialized( $wdkit_licence ) ) {
750 - $wdkit_licence = unserialize( $wdkit_licence );
855 + $wdkit_licence = unserialize( $wdkit_licence, array( 'allowed_classes' => false ) );
751 856 }
752 857 if ( ! empty( $wdkit_licence ) && is_array( $wdkit_licence ) ) {
753 858 update_option( 'wdkit_licence_data', $wdkit_licence );
754 859 }
@@ -779,8 +884,16 @@
779 884 $credits = ! empty( $response['credits']['widget_limit']['meta_value'] ) ? $response['credits']['widget_limit']['meta_value'] : 10;
780 885 $server_list = ! empty( $response['widgettemplate'] ) ? $response['widgettemplate'] : array();
781 886 $db_builder_list = ! empty( $response['widgetbuilder'] ) ? $response['widgetbuilder'] : array();
782 887
888 + // Whether this call actually carried the server widget list that activation state is
889 + // derived from. Captured before the loops below, which unset() matched $server_list
890 + // entries as they go. wdkit_meta_data() calls this method with array(), and without
891 + // this flag that call rebuilt $db_widget from local widgets only — every one of which
892 + // is forced 'active' further down — and then wrote the empty result over
893 + // wkit_deactivate_widgets, erasing every deactivation the user had made.
894 + $has_server_widgets = ! empty( $server_list );
895 +
783 896 $placeholderimg = WDKIT_URL . 'assets/images/placeholder.jpg';
784 897
785 898 $local_list = $this->wdkit_get_local_widgets();
786 899
@@ -866,13 +979,31 @@
866 979 );
867 980 }
868 981 }
869 982
870 - $get_db_widget = get_option( 'wkit_deactivate_widgets', array() );
871 - if ( empty( $get_db_widget ) ) {
872 - add_option( 'wkit_deactivate_widgets', $db_widget, '', 'yes' );
873 - } else {
874 - update_option( 'wkit_deactivate_widgets', $db_widget );
983 + // Only persist activation state when the server list it is derived from was actually
984 + // supplied. See $has_server_widgets above.
985 + if ( $has_server_widgets ) {
986 + // update_option() creates the row when it is missing, so it covers both cases.
987 + // The previous add_option()/update_option() split was chosen on empty( $option ),
988 + // but wdkit_db_widgetlist() creates this row as an empty array on every install —
989 + // so the empty branch ran while the row already existed, and add_option() is a
990 + // no-op for an existing option. Deactivating from the My Widgets screen was
991 + // therefore silently discarded on effectively every site. Autoload stays 'yes',
992 + // matching the original add_option() call and wdkit_db_widgetlist().
993 + update_option( 'wkit_deactivate_widgets', $db_widget, 'yes' );
994 +
995 + // The cached widget registry bakes in wkit_deactivate_widgets membership and is
996 + // stored as a no-expiry transient, so it never self-heals. Without this the
997 + // loaders kept registering a widget the user had just switched off (and kept
998 + // hiding one they had switched back on) until the transient was flushed by hand.
999 + // The write above is not per-builder — one save can change any builder's set, and
1000 + // a widget can move between builders — so clear all four.
1001 + if ( function_exists( 'wdesignkit_invalidate_widget_registry' ) ) {
1002 + foreach ( array( 'elementor', 'gutenberg', 'gutenberg_core', 'bricks' ) as $builder_slug ) {
1003 + wdesignkit_invalidate_widget_registry( $builder_slug );
1004 + }
1005 + }
875 1006 }
876 1007
877 1008 return $final;
878 1009 }
@@ -886,8 +1017,16 @@
886 1017 $args = $this->wdkit_parse_args( $_POST );
887 1018
888 1019 $response = WDesignKit_Data_Query::get_data( 'browse_page', $args );
889 1020
1021 + if ( is_wp_error( $response ) ) {
1022 + wp_send_json( array(
1023 + 'success' => false,
1024 + 'message' => $response->get_error_message(),
1025 + ) );
1026 + wp_die();
1027 + }
1028 +
890 1029 $manage_licence = array();
891 1030 $manage_licence['theplus_elementor_addon'] = ! empty( defined( 'THEPLUS_VERSION' ) ) ? true : false;
892 1031 $manage_licence['tpag'] = ! empty( defined( 'TPGBP_VERSION' ) ) ? true : false;
893 1032 $manage_licence['elementor-pro'] = ! empty( defined( 'ELEMENTOR_PRO_VERSION' ) ) ? true : false;
@@ -923,15 +1062,14 @@
923 1062
924 1063 $user_email = strtolower( sanitize_email( $args['email'] ) );
925 1064 $response = '';
926 1065
1066 + // Bug D fix: response()->json() is Laravel syntax — causes PHP fatal. Use plain array.
927 1067 if ( empty( $user_email ) || empty( $args['template_id'] ) ) {
928 - $response = response()->json(
929 - array(
930 - 'message' => $this->e_msg_login,
931 - 'description' => $this->e_desc_login,
932 - 'success' => true,
933 - )
1068 + $response = array(
1069 + 'message' => $this->e_msg_login,
1070 + 'description' => $this->e_desc_login,
1071 + 'success' => false,
934 1072 );
935 1073
936 1074 wp_send_json( $response );
937 1075 wp_die();
@@ -1002,8 +1140,24 @@
1002 1140 }
1003 1141
1004 1142 $response = WDesignKit_Data_Query::get_data( 'save_template', $args );
1005 1143
1144 + /**
1145 + * The cloud call can come back as a WP_Error (timeout, DNS, refused) or with an
1146 + * empty / unparsable body, which json_decode()s to null. Forwarding that as-is
1147 + * makes admin-ajax answer with a literal `null` that the editor then reads
1148 + * `.id` off, killing the whole app. Normalise it to the failure shape used above.
1149 + */
1150 + if ( is_wp_error( $response ) || ! is_array( $response ) ) {
1151 + $response = array(
1152 + 'id' => 0,
1153 + 'editpage' => '',
1154 + 'message' => esc_html__( 'Template Not Saved !', 'wdesignkit' ),
1155 + 'description' => is_wp_error( $response ) ? $response->get_error_message() : esc_html__( 'Could not reach the WDesignKit server. Please try again.', 'wdesignkit' ),
1156 + 'success' => false,
1157 + );
1158 + }
1159 +
1006 1160 wp_send_json( $response );
1007 1161 wp_die();
1008 1162 }
1009 1163
@@ -1027,9 +1181,11 @@
1027 1181 'success' => false,
1028 1182 );
1029 1183 } else {
1030 1184 $temp_content = str_replace( '\\', '', $temp_content );
1031 - $temp_content = wp_remote_get( $temp_content )['body'];
1185 + // SSRF guard (CWE-918): validate the resolved host before fetching a caller-supplied URL.
1186 + $fetched = wdesignkit_safe_remote_get( $temp_content );
1187 + $temp_content = is_wp_error( $fetched ) ? '' : wp_remote_retrieve_body( $fetched );
1032 1188 $temp_content = base64_encode( $temp_content );
1033 1189
1034 1190 $args = array(
1035 1191 'token' => $token,
@@ -1067,8 +1223,13 @@
1067 1223 *
1068 1224 * @since 2.3.3
1069 1225 */
1070 1226 protected function wdkit_save_wp_images() {
1227 +
1228 + // media_sideload_image() generates every registered thumbnail size, which decodes
1229 + // the full source bitmap. Same guard as the page import.
1230 + $this->wdkit_guard_oversized_images();
1231 +
1071 1232 $image_url = isset( $_POST['image'] ) ? sanitize_text_field( $_POST['image'] ) : '';
1072 1233
1073 1234 if ( empty( $image_url ) ) {
1074 1235 $response = array(
@@ -1087,14 +1248,26 @@
1087 1248 'success' => false,
1088 1249 );
1089 1250 } else {
1090 1251 $saved_url = wp_get_attachment_url( $attachment_id );
1091 -
1252 +
1253 + // Elementor's importer skips an image only when it finds
1254 + // _elementor_source_image_hash matching sha1 of the URL it is given. The
1255 + // content we hand it now carries this local URL, so stamp the hash of that
1256 + // URL too - without it Elementor re-downloads a file already on disk and
1257 + // leaves a "-1" duplicate behind for every image on every page that uses it.
1258 + if ( $saved_url ) {
1259 + update_post_meta( $attachment_id, '_elementor_source_image_hash', sha1( $saved_url ) );
1260 +
1261 + // Same purpose for the block importer, which keys off its own meta.
1262 + update_post_meta( $attachment_id, 'tpgb_source_image_key', sha1( $saved_url ) );
1263 + }
1264 +
1092 1265 $response = array(
1093 1266 'message' => __( 'Image Saved', 'wdesignkit' ),
1094 1267 'description' => __( 'Image successfully saved to Media Library.', 'wdesignkit' ),
1095 1268 'success' => true,
1096 - 'url' => $saved_url,
1269 + 'url' => $saved_url,
1097 1270 );
1098 1271 }
1099 1272
1100 1273 }
@@ -1108,8 +1281,235 @@
1108 1281 * Get Elementor Global color and Typography.
1109 1282 *
1110 1283 * @since 1.1.16
1111 1284 */
1285 + /**
1286 + * Kit settings holding The Plus Addons' own globals.
1287 + *
1288 + * These sit in the Elementor kit's `_elementor_page_settings` alongside Elementor's
1289 + * system_colors / system_typography, but the save flow only ever collected the four
1290 + * Elementor keys. Widgets reference an entry in these lists by its `_id` through a
1291 + * `tp_global_preset` setting, so a template saved without them travels with the
1292 + * reference but not the definition - which is why imported sections come in missing
1293 + * their button styling, radii and shadows.
1294 + *
1295 + * @since 2.6.4
1296 + *
1297 + * @return array Kit setting keys.
1298 + */
1299 + private function wdkit_tp_global_kit_keys() {
1300 + return array(
1301 + 'tp_global_button_style_list',
1302 + 'tp_global_dimensions_list',
1303 + 'tp_global_box_shadow_list',
1304 + 'tp_global_gradient_list',
1305 + 'tp_global_gsap_list',
1306 + 'tp_global_scroll_animation_list',
1307 + 'tp_text_global_gsap_list',
1308 + 'tp_image_global_gsap_list',
1309 + );
1310 + }
1311 +
1312 + /**
1313 + * Merge incoming Plus globals into the active kit, keyed by `_id`.
1314 + *
1315 + * Entries are matched on their `_id`, never on position: an existing entry is always
1316 + * left as it is, and only genuinely new ones are appended. That matters because
1317 + * widgets - and the entries themselves, a button style points at dimension and shadow
1318 + * entries - resolve by `_id`. Renumbering or overwriting would repoint references on
1319 + * the destination site's own content.
1320 + *
1321 + * @since 2.6.4
1322 + *
1323 + * @param array $incoming Lists captured with the template.
1324 + * @return bool True when the kit was changed.
1325 + */
1326 + /**
1327 + * Global colour / typography ids this site already defines.
1328 + *
1329 + * @since 2.6.4
1330 + *
1331 + * @param array $kit_meta Kit `_elementor_page_settings`.
1332 + * @return array{color:array<string,bool>,typography:array<string,bool>}
1333 + */
1334 + private function wdkit_known_global_ids( $kit_meta ) {
1335 + $known = array(
1336 + 'color' => array(),
1337 + 'typography' => array(),
1338 + );
1339 +
1340 + $sources = array(
1341 + 'color' => array( 'system_colors', 'custom_colors' ),
1342 + 'typography' => array( 'system_typography', 'custom_typography' ),
1343 + );
1344 +
1345 + foreach ( $sources as $kind => $keys ) {
1346 + foreach ( $keys as $key ) {
1347 + if ( empty( $kit_meta[ $key ] ) || ! is_array( $kit_meta[ $key ] ) ) {
1348 + continue;
1349 + }
1350 +
1351 + foreach ( $kit_meta[ $key ] as $entry ) {
1352 + if ( ! empty( $entry['_id'] ) ) {
1353 + $known[ $kind ][ $entry['_id'] ] = true;
1354 + }
1355 + }
1356 + }
1357 + }
1358 +
1359 + return $known;
1360 + }
1361 +
1362 + /**
1363 + * Make one incoming Plus global's colour / font references resolvable here.
1364 + *
1365 + * A Plus global can point at an Elementor global: the "Primary Button" entry holds
1366 + * `__globals__: { text_color: "globals/colors?id=72e09b4", … }`, which The Plus Addons
1367 + * turns into `var(--e-global-color-72e09b4)`. Elementor only emits that variable for ids
1368 + * present in the kit, so on a site without `72e09b4` the button renders with no colour.
1369 + *
1370 + * Two cases, and the difference is deliberate:
1371 + *
1372 + * - The site ALREADY defines that id — leave the reference alone. The button then picks
1373 + * up the destination's own colour, which is the point of a global. Their palette is
1374 + * never read from or written to beyond this check.
1375 + * - The site does NOT define it — write the captured value straight into the entry and
1376 + * drop the reference, so it renders as designed.
1377 + *
1378 + * Nothing is ever added to the user's global colours or fonts. An earlier version injected
1379 + * the missing definitions into their palette, which made the reference resolve but grew
1380 + * their Site Settings by every colour an imported template happened to use.
1381 + *
1382 + * @since 2.6.4
1383 + *
1384 + * @param array $entry One repeater entry.
1385 + * @param array $refs Definitions captured with the template.
1386 + * @param array $known Ids this site defines, from wdkit_known_global_ids().
1387 + * @return array Entry, with unresolvable references replaced by their values.
1388 + */
1389 + private function wdkit_resolve_entry_globals( $entry, $refs, $known ) {
1390 + if ( empty( $entry['__globals__'] ) || ! is_array( $entry['__globals__'] ) ) {
1391 + return $entry;
1392 + }
1393 +
1394 + foreach ( $entry['__globals__'] as $control => $ref ) {
1395 + if ( ! is_string( $ref ) || false === strpos( $ref, 'id=' ) ) {
1396 + continue;
1397 + }
1398 +
1399 + if ( false !== strpos( $ref, 'globals/colors' ) ) {
1400 + $kind = 'color';
1401 + } elseif ( false !== strpos( $ref, 'globals/typography' ) ) {
1402 + $kind = 'typography';
1403 + } else {
1404 + continue;
1405 + }
1406 +
1407 + $id = substr( $ref, strpos( $ref, 'id=' ) + 3 );
1408 + if ( '' === $id || isset( $known[ $kind ][ $id ] ) ) {
1409 + // Defined here already — their value wins.
1410 + continue;
1411 + }
1412 +
1413 + $definition = null;
1414 + foreach ( ( $refs[ $kind ] ?? array() ) as $candidate ) {
1415 + if ( is_array( $candidate ) && ( $candidate['_id'] ?? '' ) === $id ) {
1416 + $definition = $candidate;
1417 + break;
1418 + }
1419 + }
1420 +
1421 + if ( null === $definition ) {
1422 + // Nothing captured for it, so leave the reference rather than blank the field.
1423 + continue;
1424 + }
1425 +
1426 + if ( 'color' === $kind ) {
1427 + if ( empty( $definition['color'] ) ) {
1428 + continue;
1429 + }
1430 +
1431 + $entry[ $control ] = $definition['color'];
1432 + } else {
1433 + // A typography global expands into its own set of controls: the reference is
1434 + // held under e.g. `typography_typography`, and each definition key replaces
1435 + // that suffix — `typography_font_family`, `typography_font_weight`, and so on.
1436 + foreach ( $definition as $def_key => $def_value ) {
1437 + if ( '_id' === $def_key || 'title' === $def_key ) {
1438 + continue;
1439 + }
1440 +
1441 + $entry[ str_replace( 'typography_typography', $def_key, $control ) ] = $def_value;
1442 + }
1443 + }
1444 +
1445 + unset( $entry['__globals__'][ $control ] );
1446 + }
1447 +
1448 + return $entry;
1449 + }
1450 +
1451 + private function wdkit_merge_tp_globals( $incoming, $refs = array() ) {
1452 + if ( empty( $incoming ) || ! is_array( $incoming ) ) {
1453 + return false;
1454 + }
1455 +
1456 + $kit_id = get_option( 'elementor_active_kit' );
1457 + if ( empty( $kit_id ) ) {
1458 + return false;
1459 + }
1460 +
1461 + $kit_meta = get_post_meta( $kit_id, '_elementor_page_settings', true );
1462 + if ( ! is_array( $kit_meta ) ) {
1463 + $kit_meta = array();
1464 + }
1465 +
1466 + // Which global ids this site already defines. The Plus Addons turns a reference into
1467 + // var(--e-global-color-<_id>), and Elementor only emits that variable for ids in the
1468 + // kit — so a reference the destination does not define resolves to nothing at all.
1469 + $known = $this->wdkit_known_global_ids( $kit_meta );
1470 +
1471 + $changed = false;
1472 +
1473 + foreach ( $this->wdkit_tp_global_kit_keys() as $key ) {
1474 + if ( empty( $incoming[ $key ] ) || ! is_array( $incoming[ $key ] ) ) {
1475 + continue;
1476 + }
1477 +
1478 + $existing = ( ! empty( $kit_meta[ $key ] ) && is_array( $kit_meta[ $key ] ) ) ? $kit_meta[ $key ] : array();
1479 +
1480 + $seen = array();
1481 + foreach ( $existing as $entry ) {
1482 + if ( ! empty( $entry['_id'] ) ) {
1483 + $seen[ $entry['_id'] ] = true;
1484 + }
1485 + }
1486 +
1487 + foreach ( $incoming[ $key ] as $entry ) {
1488 + if ( ! is_array( $entry ) || empty( $entry['_id'] ) || isset( $seen[ $entry['_id'] ] ) ) {
1489 + continue;
1490 + }
1491 +
1492 + // Only ever rewrite the entry being added — never one already in the kit.
1493 + $existing[] = $this->wdkit_resolve_entry_globals( $entry, $refs, $known );
1494 + $seen[ $entry['_id'] ] = true;
1495 + $changed = true;
1496 + }
1497 +
1498 + $kit_meta[ $key ] = array_values( $existing );
1499 + }
1500 +
1501 + if ( $changed ) {
1502 + update_post_meta( $kit_id, '_elementor_page_settings', $kit_meta );
1503 +
1504 + // Writing kit meta directly does not rebuild the kit stylesheet, so the
1505 + // merged globals would never reach the frontend.
1506 + $this->wdkit_regenerate_elementor_kit_css();
1507 + }
1508 +
1509 + return $changed;
1510 + }
1511 +
1112 1512 protected function wdkit_get_global_val() {
1113 1513
1114 1514 $builder = isset( $_POST['builder'] ) ? strtolower( sanitize_text_field( $_POST['builder'] ) ) : '';
1115 1515
@@ -1851,8 +2251,13 @@
1851 2251 $site_data = ! empty( $_POST['site_data'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['site_data'] ) ), true ) : array();
1852 2252
1853 2253 if ( 'elementor' == $builder ) {
1854 2254 $kit_id = get_option( 'elementor_active_kit' );
2255 + if ( ! $kit_id && did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Core\Kits\Manager' ) ) {
2256 + \Elementor\Core\Kits\Manager::create_default_kit();
2257 + $kit_id = get_option( 'elementor_active_kit' );
2258 + }
2259 +
1855 2260 if ( ! $kit_id ) {
1856 2261 $response = array(
1857 2262 'message' => __( 'Elementor kit not found', 'wdesignkit' ),
1858 2263 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
@@ -1862,18 +2267,13 @@
1862 2267 wp_send_json( $response );
1863 2268 wp_die();
1864 2269 }
1865 2270
2271 + // A freshly created kit has no `_elementor_page_settings` meta yet,
2272 + // so an empty result here is a valid starting point, not an error.
1866 2273 $kit_meta = get_post_meta( $kit_id, '_elementor_page_settings', true );
1867 - if ( empty( $kit_meta ) ) {
1868 - $response = array(
1869 - 'message' => __( 'Data Not Found', 'wdesignkit' ),
1870 - 'description' => __( 'No site data found in kit', 'wdesignkit' ),
1871 - 'success' => false,
1872 - );
1873 -
1874 - wp_send_json( $response );
1875 - wp_die();
2274 + if ( ! is_array( $kit_meta ) ) {
2275 + $kit_meta = array();
1876 2276 }
1877 2277
1878 2278 $kit_meta['container_width'] = ! empty( $site_data['container_width'] ) ? $site_data['container_width'] : array();
1879 2279 $kit_meta['__globals__'] = ! empty( $site_data['globals'] ) ? $site_data['globals'] : array();
@@ -1880,8 +2280,13 @@
1880 2280 $kit_meta['body_background_color'] = ! empty( $site_data['body_background_color'] ) ? $site_data['body_background_color'] : array();
1881 2281
1882 2282 update_post_meta( $kit_id, '_elementor_page_settings', $kit_meta );
1883 2283
2284 + // Regenerate Elementor's cached CSS. Writing the kit meta directly does
2285 + // not rebuild the kit stylesheet, so the imported body background colour
2286 + // and container width would otherwise never render on the frontend.
2287 + $this->wdkit_regenerate_elementor_kit_css();
2288 +
1884 2289 $response = array(
1885 2290 'message' => __( 'Site data Updated', 'wdesignkit' ),
1886 2291 'description' => __( 'Site Globals Updated', 'wdesignkit' ),
1887 2292 'success' => true,
@@ -1929,8 +2334,16 @@
1929 2334 $g_typo = ! empty( $_POST['g_typography'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['g_typography'] ) ), true ) : array();
1930 2335
1931 2336 // Get colors from Elementor Site Kit
1932 2337 $kit_id = get_option( 'elementor_active_kit' );
2338 + if ( ! $kit_id && did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Core\Kits\Manager' ) ) {
2339 + // No kit has ever been created on this site (the option is only
2340 + // ever populated by Elementor's own activation hook). Create one
2341 + // via Elementor's own helper so the import has somewhere to write.
2342 + \Elementor\Core\Kits\Manager::create_default_kit();
2343 + $kit_id = get_option( 'elementor_active_kit' );
2344 + }
2345 +
1933 2346 if ( ! $kit_id ) {
1934 2347 $response = array(
1935 2348 'message' => __( 'Elementor kit not found', 'wdesignkit' ),
1936 2349 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
@@ -1940,25 +2353,25 @@
1940 2353 wp_send_json( $response );
1941 2354 wp_die();
1942 2355 }
1943 2356
2357 + // A freshly created kit has no `_elementor_page_settings` meta yet,
2358 + // so an empty result here is a valid starting point, not an error.
1944 2359 $kit_meta = get_post_meta( $kit_id, '_elementor_page_settings', true );
1945 - if ( empty( $kit_meta ) ) {
1946 - $response = array(
1947 - 'message' => __( 'Data Not Found', 'wdesignkit' ),
1948 - 'description' => __( 'No meta data found in kit', 'wdesignkit' ),
1949 - 'success' => false,
1950 - );
1951 -
1952 - wp_send_json( $response );
1953 - wp_die();
2360 + if ( ! is_array( $kit_meta ) ) {
2361 + $kit_meta = array();
1954 2362 }
1955 2363
1956 - $kit_meta['custom_colors'] = array_merge( $g_color, $kit_meta['custom_colors'] );
1957 - $kit_meta['custom_typography'] = array_merge( $g_typo, $kit_meta['custom_typography'] );
2364 + $kit_meta['custom_colors'] = array_merge( $g_color, $kit_meta['custom_colors'] ?? array() );
2365 + $kit_meta['custom_typography'] = array_merge( $g_typo, $kit_meta['custom_typography'] ?? array() );
1958 2366
1959 2367 update_post_meta( $kit_id, '_elementor_page_settings', $kit_meta );
1960 2368
2369 + // Regenerate Elementor's cached CSS. Writing the kit meta directly does
2370 + // not rebuild the kit stylesheet, so the imported global colours and
2371 + // fonts would otherwise never render on the frontend.
2372 + $this->wdkit_regenerate_elementor_kit_css();
2373 +
1961 2374 $response = array(
1962 2375 'message' => __( 'Global data Updated', 'wdesignkit' ),
1963 2376 'description' => __( 'Global Color and Typography Updated', 'wdesignkit' ),
1964 2377 'success' => true,
@@ -1993,9 +2406,29 @@
1993 2406 wp_die();
1994 2407 }
1995 2408
1996 2409 /**
2410 + * Regenerate Elementor's cached CSS files after the active kit's
2411 + * `_elementor_page_settings` meta has been changed directly.
1997 2412 *
2413 + * Elementor renders global colours, global fonts and the body background
2414 + * colour into a cached kit stylesheet. Updating the meta via
2415 + * update_post_meta() does not rebuild that stylesheet, so imported site
2416 + * settings never reach the frontend until the cache is cleared. This
2417 + * mirrors the clear_cache() call already used by the page/section import.
2418 + *
2419 + * @since 2.3.2
2420 + *
2421 + * @return void
2422 + */
2423 + protected function wdkit_regenerate_elementor_kit_css() {
2424 + if ( did_action( 'elementor/loaded' ) && class_exists( '\Elementor\Plugin' ) ) {
2425 + \Elementor\Plugin::$instance->files_manager->clear_cache();
2426 + }
2427 + }
2428 +
2429 + /**
2430 + *
1998 2431 * Create Gutenberg page and save for re-generate css file.
1999 2432 *
2000 2433 * @since 1.2.3
2001 2434 */
@@ -2359,34 +2792,56 @@
2359 2792 $error_message = $response->get_error_message();
2360 2793
2361 2794 $result = $this->tpae_set_response( false, 'oops', 'oops', '' );
2362 2795 } else {
2363 - $theme_info = unserialize( $response['body'] );
2796 + // api.wordpress.org's theme_information response is a serialized stdClass
2797 + // (accessed below via ->name / ->download_link). allowed_classes => false
2798 + // blocks stdClass too, turning it into an __PHP_Incomplete_Class whose
2799 + // properties silently don't exist — allow only stdClass, still refusing any
2800 + // other (potentially dangerous) class the payload might reference.
2801 + $theme_info = unserialize( $response['body'], array( 'allowed_classes' => array( 'stdClass' ) ) );
2364 2802 $theme_name = $theme_info->name;
2365 2803 $theme_zip_url = $theme_info->download_link;
2366 2804
2367 - global $wp_filesystem;
2368 - // Install the theme
2369 - $theme = wp_remote_get( $theme_zip_url, array( 'timeout' => 30 ) );
2805 + // SSRF guard (CWE-918): validate the resolved host before fetching the ZIP
2806 + // referenced by the external theme_info response.
2807 + if ( ! wdesignkit_validate_external_url( $theme_zip_url ) ) {
2808 + return array(
2809 + 'message' => esc_html__( 'Theme Not Activated !', 'wdesignkit' ),
2810 + 'description' => esc_html__( 'The theme package URL is not allowed.', 'wdesignkit' ),
2811 + 'status' => 'inactive',
2812 + 'success' => false,
2813 + );
2814 + }
2370 2815
2371 2816 if ( ! function_exists( 'WP_Filesystem' ) ) {
2372 2817 require_once wp_normalize_path( ABSPATH . '/wp-admin/includes/file.php' );
2373 2818 }
2374 2819
2820 + require_once wp_normalize_path( ABSPATH . '/wp-admin/includes/class-wp-upgrader.php' );
2821 + require_once wp_normalize_path( ABSPATH . '/wp-admin/includes/theme.php' );
2822 +
2375 2823 WP_Filesystem();
2376 2824
2377 2825 $active_theme = wp_get_theme();
2378 2826 $theme_name = $active_theme->get( 'Name' );
2379 2827
2380 - $wp_filesystem->put_contents( WP_CONTENT_DIR . '/themes/' . $theme_slug . '.zip', $theme['body'] );
2381 - $zip = new ZipArchive();
2382 - if ( $zip->open( WP_CONTENT_DIR . '/themes/' . $theme_slug . '.zip' ) === true ) {
2383 - $zip->extractTo( WP_CONTENT_DIR . '/themes/' );
2384 - $zip->close();
2828 + // Install via WordPress core's Theme_Upgrader instead of manually fetching and
2829 + // ZipArchive::extractTo()'ing the remote package: core already performs the
2830 + // standard download -> unpack -> validate-package-structure -> move-into-place
2831 + // flow (including cleanup on failure) used for every trusted theme install.
2832 + $upgrader = new Theme_Upgrader( new Automatic_Upgrader_Skin() );
2833 + $install = $upgrader->install( $theme_zip_url );
2834 +
2835 + if ( is_wp_error( $install ) || ! $install ) {
2836 + return array(
2837 + 'message' => esc_html__( 'Theme Not Activated !', 'wdesignkit' ),
2838 + 'description' => is_wp_error( $install ) ? $install->get_error_message() : esc_html__( 'Theme could not be installed.', 'wdesignkit' ),
2839 + 'status' => 'inactive',
2840 + 'success' => false,
2841 + );
2385 2842 }
2386 2843
2387 - $wp_filesystem->delete( WP_CONTENT_DIR . '/themes/' . $theme_slug . '.zip' );
2388 -
2389 2844 $activate_result = switch_theme( $name );
2390 2845
2391 2846 if ( ! is_wp_error( $activate_result ) ) {
2392 2847 $response = array(
@@ -2563,8 +3018,17 @@
2563 3018
2564 3019 unset( $args['email'] );
2565 3020 $args['unique_id'] = get_option( 'wdkit_unique_id' ) ?? '';
2566 3021 $response = WDesignKit_Data_Query::get_data( $api_type, $args );
3022 +
3023 + if ( is_wp_error( $response ) ) {
3024 + wp_send_json( array(
3025 + 'success' => false,
3026 + 'message' => $response->get_error_message(),
3027 + ) );
3028 + wp_die();
3029 + }
3030 +
2567 3031 $custom_meta = isset( $_POST['custom_meta'] ) ? sanitize_text_field( wp_unslash( $_POST['custom_meta'] ) ) : false;
2568 3032
2569 3033 /** Custom meta Field */
2570 3034 if ( ! empty( $custom_meta ) && 'true' === $custom_meta && ! empty( $response ) && ! empty( $response['content'] ) ) {
@@ -2575,9 +3039,9 @@
2575 3039
2576 3040 if ( ! empty( $meta_data ) ) {
2577 3041 foreach ( $meta_data as $meta_key => $meta_val ) {
2578 3042 if ( ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
2579 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
3043 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
2580 3044 }
2581 3045
2582 3046 if ( get_post_meta( get_the_ID(), $meta_key, true ) === '' ) {
2583 3047 add_post_meta( get_the_ID(), $meta_key, $meta_val[0] );
@@ -2588,8 +3052,34 @@
2588 3052 }
2589 3053 }
2590 3054 }
2591 3055
3056 + /**
3057 + * Fires after a template has been imported from the cloud.
3058 + *
3059 + * WDesignKit's templates live in the cloud, so nothing local records that an import
3060 + * happened — there is no post type, no option, nothing to count after the fact. This is the
3061 + * only moment the information exists.
3062 + *
3063 + * @since 2.6.4
3064 + *
3065 + * @param string $kind 'single' or 'kit'.
3066 + * @param string $builder Builder the template was imported for, e.g. 'elementor'.
3067 + * @param int $count How many templates this import brought in.
3068 + */
3069 + // Only a completed import counts. The cloud's failure shape for this endpoint family sets
3070 + // content => 'error' (see the sibling check in wdkit_import_kit_template() above) — that is
3071 + // non-empty, so the previous `||` fired the counter on failed imports too. Require success
3072 + // AND an absent/non-'error' content instead.
3073 + if ( ! empty( $response['success'] ) && ( ! isset( $response['content'] ) || 'error' !== $response['content'] ) ) {
3074 + do_action(
3075 + 'wdkit_template_imported',
3076 + 'import_kit_template' === $api_type ? 'kit' : 'single',
3077 + isset( $_POST['builder'] ) ? sanitize_key( wp_unslash( $_POST['builder'] ) ) : '',
3078 + 1
3079 + );
3080 + }
3081 +
2592 3082 wp_send_json( $response );
2593 3083 wp_die();
2594 3084 }
2595 3085
@@ -2600,8 +3090,265 @@
2600 3090 *
2601 3091 * @param array $content store media content.
2602 3092 * @param string $editor it is check editor.
2603 3093 */
3094 + /**
3095 + * Resolve a local upload URL back to its attachment ID.
3096 + *
3097 + * Handles the "-scaled" copy WordPress makes for large originals and any
3098 + * "-1920x1280" size suffix, both of which attachment_url_to_postid() misses because
3099 + * they are not the value stored in _wp_attached_file.
3100 + *
3101 + * @since 2.6.2
3102 + *
3103 + * @param string $url Local upload URL.
3104 + * @return int Attachment ID, or 0.
3105 + */
3106 + private static function wdkit_attachment_id_from_url( $url ) {
3107 + static $cache = array();
3108 +
3109 + if ( isset( $cache[ $url ] ) ) {
3110 + return $cache[ $url ];
3111 + }
3112 +
3113 + $id = (int) attachment_url_to_postid( $url );
3114 +
3115 + if ( ! $id ) {
3116 + // Try the original file behind a -scaled or -WxH derivative.
3117 + $stripped = preg_replace( '/-scaled(\.[a-z0-9]+)$/i', '$1', $url );
3118 + $stripped = preg_replace( '/-\d+x\d+(\.[a-z0-9]+)$/i', '$1', (string) $stripped );
3119 +
3120 + if ( $stripped && $stripped !== $url ) {
3121 + $id = (int) attachment_url_to_postid( $stripped );
3122 + }
3123 + }
3124 +
3125 + // Only remember hits. Page imports run concurrently, so an attachment created by a
3126 + // sibling request may not exist yet when this is first asked — caching that miss
3127 + // would keep every later control in this request pointing at nothing.
3128 + if ( $id ) {
3129 + $cache[ $url ] = $id;
3130 + }
3131 +
3132 + return $id;
3133 + }
3134 +
3135 + /**
3136 + * Is this media reference still pointing off-site?
3137 + *
3138 + * Template content arrives holding the URLs of wherever the media lived before. Those
3139 + * carry that site's attachment IDs, which have no meaning here - and can collide with
3140 + * unrelated local posts.
3141 + *
3142 + * @since 2.6.2
3143 + *
3144 + * @param string $url URL from a media control.
3145 + * @return bool True when the URL points at another site's uploads.
3146 + */
3147 + private static function wdkit_is_foreign_media_url( $url ) {
3148 +
3149 + if ( ! class_exists( 'Wdkit_Image_Guard' ) ) {
3150 + require_once WDKIT_INCLUDES . 'admin/class-wdkit-image-guard.php';
3151 + }
3152 +
3153 + $uploads = wp_get_upload_dir();
3154 +
3155 + return Wdkit_Image_Guard::is_foreign_media( $url, isset( $uploads['baseurl'] ) ? $uploads['baseurl'] : '' );
3156 + }
3157 +
3158 + /**
3159 + * Find - or make - the local attachment behind a source-site media URL.
3160 + *
3161 + * Elementor stamps every image it imports with `_elementor_source_image_hash`
3162 + * (sha1 of the URL it came from), and its importer consults that before doing any
3163 + * network work. Delegating here means a URL already imported at create time resolves
3164 + * from the database, and one that never made it is fetched exactly once.
3165 + *
3166 + * Only ever called for foreign URLs. Handing it a local URL would re-download the
3167 + * file and leave a duplicate, because the stored hash is of the *remote* URL and so
3168 + * would never match.
3169 + *
3170 + * @since 2.6.2
3171 + *
3172 + * @param string $url Source-site media URL.
3173 + * @param int $source_id The source site's attachment ID, used as Elementor's cache key.
3174 + * @return array Local `id` and `url`, or an empty array when it cannot be resolved.
3175 + */
3176 + private static function wdkit_localise_media_url( $url, $source_id = 0 ) {
3177 + static $cache = array();
3178 +
3179 + if ( isset( $cache[ $url ] ) ) {
3180 + return $cache[ $url ];
3181 + }
3182 +
3183 + if ( ! did_action( 'elementor/loaded' ) || ! class_exists( '\\Elementor\\Plugin' ) ) {
3184 + return array();
3185 + }
3186 +
3187 + $images = \Elementor\Plugin::$instance->templates_manager->get_import_images_instance();
3188 +
3189 + if ( ! $images ) {
3190 + return array();
3191 + }
3192 +
3193 + // A download may happen, so keep the oversized-image guard in force.
3194 + self::wdkit_guard_oversized_images();
3195 +
3196 + $imported = $images->import(
3197 + array(
3198 + // Elementor only checks its hash table when an id is present.
3199 + 'id' => $source_id ? $source_id : 1,
3200 + 'url' => $url,
3201 + )
3202 + );
3203 +
3204 + $local = ( ! empty( $imported['id'] ) && ! empty( $imported['url'] ) )
3205 + ? array(
3206 + 'id' => (int) $imported['id'],
3207 + 'url' => $imported['url'],
3208 + )
3209 + : array();
3210 +
3211 + // Remember hits only: a sibling request importing concurrently may simply not have
3212 + // finished yet, and caching that miss would strand every later control on this page.
3213 + if ( $local ) {
3214 + $cache[ $url ] = $local;
3215 + }
3216 +
3217 + return $local;
3218 + }
3219 +
3220 + /**
3221 + * Repair dangling attachment IDs across every page of a finished import.
3222 + *
3223 + * The create-time repair in wdkit_media_import() can only see attachments that already
3224 + * exist. Pages import concurrently and share images — an icon first imported by one
3225 + * page is referenced by several others — so a page that runs early legitimately cannot
3226 + * resolve an image a sibling request has not created yet.
3227 + *
3228 + * This runs at the finalize step, once every page and attachment exists, and fixes
3229 + * whatever the per-page pass had to leave behind.
3230 + *
3231 + * @since 2.6.2
3232 + *
3233 + * @param array $page_ids Imported post IDs.
3234 + * @return int Number of pages actually rewritten.
3235 + */
3236 + private function wdkit_sweep_attachment_ids( $page_ids ) {
3237 +
3238 + if ( empty( $page_ids ) || ! did_action( 'elementor/loaded' ) ) {
3239 + return 0;
3240 + }
3241 +
3242 + $fixed = 0;
3243 + $ids = array_unique( array_map( 'intval', $page_ids ) );
3244 +
3245 + // Primes the meta cache for the whole batch in one query, so the
3246 + // get_post_meta() call below hits the cache instead of issuing one query
3247 + // per imported page.
3248 + update_meta_cache( 'post', $ids );
3249 +
3250 + foreach ( $ids as $post_id ) {
3251 +
3252 + if ( ! $post_id ) {
3253 + continue;
3254 + }
3255 +
3256 + $raw = get_post_meta( $post_id, '_elementor_data', true );
3257 +
3258 + if ( empty( $raw ) ) {
3259 + continue;
3260 + }
3261 +
3262 + $data = is_array( $raw ) ? $raw : json_decode( $raw, true );
3263 +
3264 + if ( ! is_array( $data ) ) {
3265 + continue;
3266 + }
3267 +
3268 + $repaired = self::wdkit_repair_attachment_ids( $data );
3269 +
3270 + if ( wp_json_encode( $repaired ) === wp_json_encode( $data ) ) {
3271 + continue;
3272 + }
3273 +
3274 + // Save through the document API so Elementor regenerates the page CSS — the
3275 + // background-image rules are only emitted once the IDs resolve.
3276 + $document = \Elementor\Plugin::$instance->documents->get( $post_id );
3277 +
3278 + // Count only a save that actually happened. Document::save() returns false
3279 + // without saving when the current user cannot edit the post, and reporting
3280 + // those as repaired hides the fact that nothing changed.
3281 + if ( $document && $document->save( array( 'elements' => $repaired ) ) ) {
3282 + ++$fixed;
3283 + }
3284 + }
3285 +
3286 + if ( $fixed ) {
3287 + \Elementor\Plugin::$instance->files_manager->clear_cache();
3288 + }
3289 +
3290 + return $fixed;
3291 + }
3292 +
3293 + /**
3294 + * Repair media controls whose attachment ID does not resolve.
3295 + *
3296 + * Elementor media controls store `{ url, id }`. Controls flagged `has_sizes` — the
3297 + * container/section **background image** among them — do not render from `url` at all:
3298 + * CSS generation resolves the image through the attachment ID, so a dangling ID
3299 + * produces no `background-image` rule and the section renders with no image even
3300 + * though its URL is perfectly correct.
3301 + *
3302 + * IDs arrive dangling whenever Elementor's own importer does not rewrite a control —
3303 + * it carries the source site's ID, which means nothing locally. Now that the URL is
3304 + * already a local upload before import, the ID can simply be looked up from it.
3305 + *
3306 + * @since 2.6.2
3307 + *
3308 + * @param mixed $node Elementor data, walked recursively.
3309 + * @return mixed Data with resolvable attachment IDs.
3310 + */
3311 + private static function wdkit_repair_attachment_ids( $node ) {
3312 +
3313 + if ( ! is_array( $node ) ) {
3314 + return $node;
3315 + }
3316 +
3317 + // A media control value: has a url, and an id slot to correct.
3318 + if ( isset( $node['url'] ) && is_string( $node['url'] ) && array_key_exists( 'id', $node ) ) {
3319 +
3320 + $current = (int) $node['id'];
3321 + $is_live = $current && 'attachment' === get_post_type( $current );
3322 +
3323 + if ( self::wdkit_is_foreign_media_url( $node['url'] ) ) {
3324 + // Still pointing at the source site. Ask Elementor for the local copy: its
3325 + // _elementor_source_image_hash lookup returns the attachment the create-time
3326 + // import already made, so this normally costs a single query and no download.
3327 + $local = self::wdkit_localise_media_url( $node['url'], $current );
3328 +
3329 + if ( ! empty( $local['id'] ) && ! empty( $local['url'] ) ) {
3330 + $node['id'] = $local['id'];
3331 + $node['url'] = $local['url'];
3332 + }
3333 + } elseif ( ! $is_live && false !== strpos( $node['url'], '/wp-content/uploads/' ) ) {
3334 + $resolved = self::wdkit_attachment_id_from_url( $node['url'] );
3335 +
3336 + if ( $resolved ) {
3337 + $node['id'] = $resolved;
3338 + }
3339 + }
3340 + }
3341 +
3342 + foreach ( $node as $key => $value ) {
3343 + if ( is_array( $value ) ) {
3344 + $node[ $key ] = self::wdkit_repair_attachment_ids( $value );
3345 + }
3346 + }
3347 +
3348 + return $node;
3349 + }
3350 +
2604 3351 public function wdkit_media_import( $content = array(), $editor = '' ) {
2605 3352
2606 3353 if ( empty( $content ) && empty( $editor ) ) {
2607 3354 $args = $this->wdkit_parse_args( $_POST );
@@ -2621,8 +3368,9 @@
2621 3368 if ( ! class_exists( 'Wdkit_Import_Images' ) ) {
2622 3369 require_once WDKIT_INCLUDES . 'admin/class-wdkit-import-images.php';
2623 3370 }
2624 3371
3372 +
2625 3373 if ( ! empty( $args['editor'] ) && 'gutenberg' === $args['editor'] && ! empty( $content ) ) {
2626 3374 $media_import = array( $content );
2627 3375 $media_import = self::blocks_import_media_copy_content( $media_import );
2628 3376 $content = $media_import[0];
@@ -2630,8 +3378,13 @@
2630 3378 $media_import = array( $content );
2631 3379 $media_import = self::widgets_elements_id_change( $media_import );
2632 3380 $media_import = self::widgets_import_media_copy_content( $media_import );
2633 3381 $content = $media_import[0];
3382 +
3383 + // Last: point any control Elementor left holding a foreign attachment ID at the
3384 + // local attachment its URL already refers to. Without this, has_sizes controls
3385 + // such as container background images resolve to nothing and render empty.
3386 + $content = self::wdkit_repair_attachment_ids( $content );
2634 3387 }
2635 3388
2636 3389 return $content;
2637 3390 }
@@ -2702,9 +3455,13 @@
2702 3455 $control_type = \Elementor\Plugin::instance()->controls_manager->get_control( $get_control['type'] );
2703 3456 $control_name = $get_control['name'];
2704 3457
2705 3458 if ( ! $control_type ) {
2706 - return $get_element_instance;
3459 + // Skip just this control. Returning here would abandon every control after
3460 + // it, so a single unregistered type - routine when a kit uses an addon that
3461 + // is not fully active yet - would silently leave the rest of the element's
3462 + // media pointing at the source site.
3463 + continue;
2707 3464 }
2708 3465
2709 3466 if ( method_exists( $control_type, $tp_mi_on_fun ) ) {
2710 3467 $get_element_instance['settings'][ $control_name ] = $control_type->{$tp_mi_on_fun}( $element->get_settings( $control_name ), $get_control );
@@ -2779,73 +3536,298 @@
2779 3536 public static function blocks_data_instance( array $block_data, array $args = array(), $block_args = null ) {
2780 3537
2781 3538 if ( ( isset( $block_data['name'] ) && isset( $block_data['clientId'] ) && isset( $block_data['attributes'] ) ) || ( isset( $block_data['blockName'] ) && isset( $block_data['attrs'] ) && ! empty( $block_data['attrs'] ) ) ) {
2782 3539 $blocks_attr = isset( $block_data['attributes'] ) ? $block_data['attributes'] : ( isset( $block_data['attrs'] ) ? $block_data['attrs'] : array() );
2783 - foreach ( $blocks_attr as $block_key => $block_val ) {
2784 - if ( isset( $block_val['url'] ) && isset( $block_val['id'] ) && ! empty( $block_val['url'] ) ) {
2785 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $block_val );
2786 - $blocks_attr[ $block_key ] = $new_media;
2787 - } elseif ( isset( $block_val['url'] ) && ! empty( $block_val['url'] ) && preg_match( '/\.(jpg|png|jpeg|gif|svg|webp)$/', $block_val['url'] ) ) {
2788 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $block_val );
2789 - $blocks_attr[ $block_key ] = $new_media;
2790 - } elseif ( is_array( $block_val ) && ! empty( $block_val ) ) {
2791 - if ( ! array_key_exists( 'md', $block_val ) && ! array_key_exists( 'openTypography', $block_val ) && ! array_key_exists( 'openBorder', $block_val ) && ! array_key_exists( 'openShadow', $block_val ) && ! array_key_exists( 'openFilter', $block_val ) ) {
2792 - foreach ( $block_val as $key => $val ) {
2793 - if ( is_array( $val ) && ! empty( $val ) ) {
3540 + $blocks_attr = self::wdkit_import_block_media( $blocks_attr );
3541 + if ( isset( $block_data['attributes'] ) ) {
3542 + $block_data['attributes'] = $blocks_attr;
3543 + } elseif ( isset( $block_data['attrs'] ) ) {
3544 + $block_data['attrs'] = $blocks_attr;
3545 + }
2794 3546
2795 - if ( isset( $val['url'] ) && ( isset( $val['Id'] ) || isset( $val['id'] ) ) && ! empty( $val['url'] ) ) {
2796 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $val );
2797 - $blocks_attr[ $block_key ][ $key ] = $new_media;
2798 - } elseif ( isset( $val['url'] ) && ! empty( $val['url'] ) && preg_match( '/\.(jpg|png|jpeg|gif|svg|webp)$/', $val['url'] ) ) {
2799 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $val );
2800 - $blocks_attr[ $block_key ][ $key ] = $new_media;
2801 - } else {
2802 - foreach ( $val as $sub_key => $sub_val ) {
2803 - if ( isset( $sub_val['url'] ) && ( isset( $sub_val['Id'] ) || isset( $sub_val['id'] ) ) && ! empty( $sub_val['url'] ) ) {
2804 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $sub_val );
3547 + $block_data = self::wdkit_relink_block_markup( $block_data );
3548 + }
2805 3549
2806 - if ( is_array( $sub_val ) && is_array( $new_media ) ) {
2807 - $blocks_attr[ $block_key ][ $key ][ $sub_key ] = array_merge( $sub_val, $new_media );
2808 - } else {
2809 - $blocks_attr[ $block_key ][ $key ][ $sub_key ] = $new_media;
2810 - }
2811 - } elseif ( isset( $sub_val['url'] ) && ! empty( $sub_val['url'] ) && preg_match( '/\.(jpg|png|jpeg|gif|svg|webp)$/', $sub_val['url'] ) ) {
2812 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $sub_val );
2813 - $blocks_attr[ $block_key ][ $key ][ $sub_key ] = $new_media;
2814 - } elseif ( is_array( $sub_val ) && ! empty( $sub_val ) ) {
2815 - foreach ( $sub_val as $sub_key1 => $sub_val1 ) {
2816 - if ( isset( $sub_val1['url'] ) && ( isset( $sub_val1['Id'] ) || isset( $sub_val1['id'] ) ) && ! empty( $sub_val1['url'] ) ) {
2817 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $sub_val1 );
3550 + return $block_data;
3551 + }
2818 3552
2819 - if ( is_array( $sub_val1 ) && is_array( $new_media ) ) {
2820 - $blocks_attr[ $block_key ][ $key ][ $sub_key ][ $sub_key1 ] = array_merge( $sub_val1, $new_media );
2821 - } else {
2822 - $blocks_attr[ $block_key ][ $key ][ $sub_key ][ $sub_key1 ] = $new_media;
2823 - }
2824 - } elseif ( isset( $sub_val1['url'] ) && ! empty( $sub_val1['url'] ) && preg_match( '/\.(jpg|png|jpeg|gif|svg|webp)$/', $sub_val1['url'] ) ) {
2825 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $sub_val1 );
2826 - $blocks_attr[ $block_key ][ $key ][ $sub_key ][ $sub_key1 ] = $new_media;
2827 - }
2828 - }
2829 - }
2830 - }
2831 - }
2832 - }
2833 - }
2834 - }
3553 + /**
3554 + * Run block markup through the media import, the way the create path does.
3555 + *
3556 + * Used wherever block content is written from the browser: media import, then the Nexter
3557 + * block processor so each block's rendered copy matches its attributes, then serialise.
3558 + *
3559 + * @since 2.6.2
3560 + *
3561 + * @param string $content Block markup.
3562 + * @return string Block markup with local media.
3563 + */
3564 + private function wdkit_relink_gutenberg_content( $content ) {
3565 +
3566 + if ( ! is_string( $content ) || false === strpos( $content, '<!-- wp:' ) ) {
3567 + return $content;
3568 + }
3569 +
3570 + // wdkit_media_import() loads this itself, but it is referenced before that below.
3571 + if ( ! class_exists( 'Wdkit_Import_Images' ) ) {
3572 + require_once WDKIT_INCLUDES . 'admin/class-wdkit-import-images.php';
3573 + }
3574 +
3575 + // Thumbnail generation decodes each image, so keep the oversized-image guard in force.
3576 + self::wdkit_guard_oversized_images();
3577 +
3578 +
3579 + // Block attributes are JSON inside the block delimiters, so they only survive a parse
3580 + // when the string carries exactly one level of escaping. Arrive with an extra level and
3581 + // parse_blocks() reads no attributes at all - serialising that back out writes every
3582 + // block bare, throwing away titles, body text, icons and styling.
3583 + $parsable = self::wdkit_parsable_block_content( $content );
3584 +
3585 + if ( null === $parsable ) {
3586 +
3587 + return $content;
3588 + }
3589 +
3590 + $blocks = parse_blocks( $parsable );
3591 + $blocks = $this->wdkit_media_import( $blocks, 'gutenberg' );
3592 +
3593 + if ( empty( $blocks ) || ! is_array( $blocks ) ) {
3594 + return $content;
3595 + }
3596 +
3597 + if ( class_exists( 'WDKIT_Nexter_Block_Processor' ) ) {
3598 + $processor = new WDKIT_Nexter_Block_Processor();
3599 + $blocks = $processor->run( $blocks );
3600 + }
3601 +
3602 + $serialised = serialize_blocks( $blocks );
3603 +
3604 + // Last line of defence. This function exists to repoint media, so a result carrying
3605 + // fewer block attributes than it started with is a broken round trip, not a rewrite.
3606 + // Leaving the media wrong is recoverable; saving gutted content is not.
3607 + $before = self::wdkit_block_attr_count( $parsable );
3608 + $after = self::wdkit_block_attr_count( $serialised );
3609 +
3610 + if ( $after < $before ) {
3611 +
3612 + return $content;
3613 + }
3614 +
3615 + // Never hand back nothing: an empty result would blank the page.
3616 + return ! empty( $serialised ) ? $serialised : $content;
3617 + }
3618 +
3619 + /**
3620 + * Rebuild the block stylesheet for a page whose content we just rewrote.
3621 + *
3622 + * The addon keeps each block's styling in a generated per-page stylesheet, and every rule
3623 + * is keyed to the block id it was written for. That file is produced when the page is
3624 + * saved through the editor - not by wp_update_post() from an AJAX handler - so rewriting
3625 + * content here leaves the page pointing at a stylesheet built for the previous markup.
3626 + * Blocks whose ids are not in that file get no rules at all and render unstyled.
3627 + *
3628 + * @since 2.6.2
3629 + *
3630 + * @param int $post_id Page whose content changed.
3631 + * @return bool True when a rebuild was triggered.
3632 + */
3633 + private static function wdkit_rebuild_block_css( $post_id ) {
3634 +
3635 + if ( ! $post_id ) {
3636 + return false;
3637 + }
3638 +
3639 + foreach ( get_declared_classes() as $class ) {
3640 + if ( ! method_exists( $class, 'make_block_css_by_post_id' ) ) {
3641 + continue;
3642 + }
3643 +
3644 + try {
3645 + if ( method_exists( $class, 'instance' ) ) {
3646 + $instance = $class::instance();
3647 + } elseif ( method_exists( $class, 'get_instance' ) ) {
3648 + $instance = $class::get_instance();
3649 + } else {
3650 + $instance = new $class();
2835 3651 }
3652 +
3653 + $instance->make_block_css_by_post_id( $post_id );
3654 +
3655 +
3656 + return true;
3657 + } catch ( \Throwable $e ) {
3658 + // Styling is best-effort: a failure here must not fail the import.
3659 +
3660 + return false;
2836 3661 }
2837 - if ( isset( $block_data['attributes'] ) ) {
2838 - $block_data['attributes'] = $blocks_attr;
2839 - } elseif ( isset( $block_data['attrs'] ) ) {
2840 - $block_data['attrs'] = $blocks_attr;
3662 + }
3663 +
3664 + return false;
3665 + }
3666 +
3667 + /**
3668 + * How many block attributes does this markup actually yield when parsed?
3669 + *
3670 + * Used as a before/after measure: block attributes are the part of block markup a round
3671 + * trip can silently drop, so counting them is how we tell a rewrite from a mangling.
3672 + *
3673 + * @since 2.6.2
3674 + *
3675 + * @param string $content Block markup.
3676 + * @return int Total attributes across every block.
3677 + */
3678 + private static function wdkit_block_attr_count( $content ) {
3679 + $total = 0;
3680 +
3681 + $walk = function ( $blocks ) use ( &$walk, &$total ) {
3682 + foreach ( $blocks as $block ) {
3683 + if ( ! empty( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
3684 + $total += count( $block['attrs'] );
3685 + }
3686 +
3687 + if ( ! empty( $block['innerBlocks'] ) ) {
3688 + $walk( $block['innerBlocks'] );
3689 + }
2841 3690 }
3691 + };
3692 +
3693 + $walk( parse_blocks( (string) $content ) );
3694 +
3695 + return $total;
3696 + }
3697 +
3698 + /**
3699 + * Return this content in a form whose block attributes actually parse.
3700 + *
3701 + * Content written straight to post_content never had to parse, so an extra level of
3702 + * escaping on the way in did no harm. Parsing it - which repointing media requires - makes
3703 + * that escaping fatal: `{\"Title\":\"…\"}` is not JSON, so every attribute is discarded.
3704 + *
3705 + * Rather than assume a slash depth, this measures: if stripping one level yields more
3706 + * attributes, the content was over-escaped and the stripped form is the real one.
3707 + *
3708 + * @since 2.6.2
3709 + *
3710 + * @param string $content Block markup as received.
3711 + * @return string|null Markup safe to parse, or null when no form of it parses.
3712 + */
3713 + private static function wdkit_parsable_block_content( $content ) {
3714 +
3715 + $as_is = self::wdkit_block_attr_count( $content );
3716 +
3717 + // Nothing claims to carry attributes, so there is nothing to lose either.
3718 + if ( false === strpos( $content, '{' ) ) {
3719 + return $content;
2842 3720 }
2843 3721
3722 + $stripped = wp_unslash( $content );
3723 + $stripped_attrs = self::wdkit_block_attr_count( $stripped );
3724 +
3725 + if ( $stripped_attrs > $as_is ) {
3726 + return $stripped;
3727 + }
3728 +
3729 + if ( $as_is > 0 ) {
3730 + return $content;
3731 + }
3732 +
3733 + // Neither form parses into attributes even though the markup contains JSON: better to
3734 + // leave the content exactly as it arrived than to rewrite it into something bare.
3735 + return null;
3736 + }
3737 +
3738 + /**
3739 + * Point a block's saved markup at the media that was just localised.
3740 + *
3741 + * A block stores a rendered copy of itself in `innerHTML` / `innerContent`, and for many
3742 + * blocks that copy is what the front end actually outputs. Importing the attributes alone
3743 + * therefore fixes the editor while leaving the page still loading from the site the
3744 + * template came from - and those hosts answer 403, so the image renders broken.
3745 + *
3746 + * @since 2.6.2
3747 + *
3748 + * @param array $block_data One parsed block.
3749 + * @return array The block with its markup repointed.
3750 + */
3751 + private static function wdkit_relink_block_markup( $block_data ) {
3752 +
3753 + $map = Wdkit_Import_Images::get_url_map();
3754 +
3755 + if ( empty( $map ) ) {
3756 + return $block_data;
3757 + }
3758 +
3759 + $from = array_keys( $map );
3760 + $to = array_values( $map );
3761 +
3762 + if ( ! empty( $block_data['innerHTML'] ) && is_string( $block_data['innerHTML'] ) ) {
3763 + $block_data['innerHTML'] = str_replace( $from, $to, $block_data['innerHTML'] );
3764 + }
3765 +
3766 + if ( ! empty( $block_data['innerContent'] ) && is_array( $block_data['innerContent'] ) ) {
3767 + foreach ( $block_data['innerContent'] as $index => $chunk ) {
3768 + if ( is_string( $chunk ) ) {
3769 + $block_data['innerContent'][ $index ] = str_replace( $from, $to, $chunk );
3770 + }
3771 + }
3772 + }
3773 +
2844 3774 return $block_data;
2845 3775 }
2846 3776
2847 3777 /**
3778 + * Import every media reference held in a block's attributes.
3779 + *
3780 + * Block attributes nest arbitrarily - a repeater of cards each with an image, responsive
3781 + * variants, nested inner settings - so this recurses rather than reaching a fixed number
3782 + * of levels down. The previous version was unrolled exactly four levels deep and also
3783 + * skipped any subtree carrying an `md` key, which meant anything below that simply kept
3784 + * the source site's URL and attachment ID and rendered as an empty placeholder.
3785 + *
3786 + * A node counts as media when it has a non-empty string `url` and either an id slot or a
3787 + * URL that names an image file. That pairing is what distinguishes a media control from
3788 + * a link, which also carries a `url`.
3789 + *
3790 + * @since 2.6.2
3791 + *
3792 + * @param mixed $node Block attributes, walked recursively.
3793 + * @return mixed Attributes with local media.
3794 + */
3795 + private static function wdkit_import_block_media( $node ) {
3796 +
3797 + if ( ! is_array( $node ) ) {
3798 + return $node;
3799 + }
3800 +
3801 + $url = isset( $node['url'] ) && is_string( $node['url'] ) ? $node['url'] : '';
3802 +
3803 + if ( '' !== $url
3804 + && ( array_key_exists( 'id', $node ) || array_key_exists( 'Id', $node )
3805 + || preg_match( '/\.(?:jpe?g|png|gif|svg|webp|avif|bmp)$/i', (string) wp_parse_url( $url, PHP_URL_PATH ) ) )
3806 + ) {
3807 + $imported = Wdkit_Import_Images::wdkit_Import_media( $node );
3808 +
3809 + // Only accept a real result. The importer returns the node untouched when it
3810 + // cannot localise the file, and anything falsy here would wipe out the URL and
3811 + // leave the block with no image at all.
3812 + if ( ! empty( $imported['url'] ) ) {
3813 + $node = array_merge( $node, $imported );
3814 + }
3815 + }
3816 +
3817 + // Keep walking even after importing this node. A media value carries its own `sizes`
3818 + // map of per-size URLs, and returning here left every one of those pointing at the
3819 + // site the template came from - which is what the widgets actually render from.
3820 + foreach ( $node as $key => $value ) {
3821 + if ( is_array( $value ) ) {
3822 + $node[ $key ] = self::wdkit_import_block_media( $value );
3823 + }
3824 + }
3825 +
3826 + return $node;
3827 + }
3828 +
3829 + /**
2848 3830 * Kit Template Import Pages/Sections
2849 3831 *
2850 3832 * @since 1.0.0
2851 3833 * */
@@ -2924,8 +3906,28 @@
2924 3906 $output['description'] = $response['description'];
2925 3907 $output['data'] = $result;
2926 3908 $output['success'] = $response['success'];
2927 3909
3910 + // Counts the IMPORT ACTION, not what it brought in. A kit import always counts as 1 kit,
3911 + // no matter how many blocks/pages that kit contains — confirmed live: a single gutenberg
3912 + // kit import recorded total=680, kinds.kit=680, because $template_ids for that call was a
3913 + // 680-element array of the kit's own blocks and count( $template_ids ) counted every one of
3914 + // them. A page-kit's *size* is not tracking's concern; "was a kit imported" is.
3915 + //
3916 + // The 'single' branch keeps a defensive fallback for the one shape this endpoint's own
3917 + // $template_ids reliably takes when it is not a kit — a single {id, name, slug, thumb...}
3918 + // object — where count() would likewise count JSON keys instead of "1 template imported".
3919 + if ( ! empty( $output['success'] ) ) {
3920 + $is_kit = ( '' !== $website_kit );
3921 + $import_count = $is_kit ? 1 : ( isset( $template_ids['id'] ) ? 1 : ( is_array( $template_ids ) ? count( $template_ids ) : 1 ) );
3922 + do_action(
3923 + 'wdkit_template_imported',
3924 + $is_kit ? 'kit' : 'single',
3925 + sanitize_key( $builder ),
3926 + $import_count
3927 + );
3928 + }
3929 +
2928 3930 wp_send_json( $output );
2929 3931 wp_die();
2930 3932 }
2931 3933
@@ -3032,9 +4034,17 @@
3032 4034 );
3033 4035
3034 4036 $response = WDesignKit_Data_Query::get_data( $api_type, $temp_args );
3035 4037
3036 - if ( 'error' === $response['content'] ) {
4038 + if ( is_wp_error( $response ) ) {
4039 + wp_send_json( array(
4040 + 'success' => false,
4041 + 'message' => $response->get_error_message(),
4042 + ) );
4043 + wp_die();
4044 + }
4045 +
4046 + if ( isset( $response['content'] ) && 'error' === $response['content'] ) {
3037 4047 wp_send_json( $response );
3038 4048 wp_die();
3039 4049 }
3040 4050
@@ -3102,8 +4112,14 @@
3102 4112 }
3103 4113
3104 4114 $document = \Elementor\Plugin::$instance->documents->get($template_id);
3105 4115
4116 + // This saves content posted straight from the browser, which carries local image
4117 + // URLs but still the source template's attachment IDs. Without repairing them the
4118 + // save undoes what wdkit_media_import() fixed on create, and has_sizes controls —
4119 + // container background images especially — resolve to nothing and render empty.
4120 + $content = self::wdkit_repair_attachment_ids( $content );
4121 +
3106 4122 $document->save([
3107 4123 'elements' => $content
3108 4124 ]);
3109 4125 }
@@ -3108,8 +4124,94 @@
3108 4124 ]);
3109 4125 }
3110 4126
3111 4127 /**
4128 + * Update the content of an already-created page.
4129 + *
4130 + * Used by the async ("Site Ready first") import path: pages are created up front with
4131 + * their un-rewritten template content, then this writes the AI-rewritten content into
4132 + * each page in the background. Elementor saves via the document API (same as
4133 + * wkit_update_elementor_template); Gutenberg writes post_content directly.
4134 + *
4135 + * @since 2.6.2
4136 + */
4137 + protected function wdkit_update_page_content() {
4138 + $post_id = isset( $_POST['post_id'] ) ? intval( $_POST['post_id'] ) : 0;
4139 + $builder = isset( $_POST['builder'] ) ? sanitize_text_field( wp_unslash( $_POST['builder'] ) ) : '';
4140 +
4141 + if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) {
4142 + return array(
4143 + 'success' => false,
4144 + 'message' => esc_html__( 'Invalid page or insufficient permission', 'wdesignkit' ),
4145 + );
4146 + }
4147 +
4148 + if ( 'gutenberg' === $builder ) {
4149 + // Do NOT run kses here: Gutenberg block delimiters are HTML comments
4150 + // (<!-- wp:... -->) which kses strips. Mirror the create path, which stores
4151 + // the block markup slashed and unfiltered (endpoint is manage_options-gated
4152 + // and the content is plugin-generated).
4153 + $content = isset( $_POST['content'] ) ? wp_unslash( $_POST['content'] ) : '';
4154 +
4155 + // This content comes straight from the browser and still carries the template
4156 + // site's media URLs and attachment IDs, so it has to go through the same pipeline
4157 + // the create path uses. Without this the save simply undid the import: the files
4158 + // were fetched, then overwritten by a copy still pointing at the source site.
4159 + //
4160 + // Re-running is cheap. Every URL already handled resolves from the source-hash
4161 + // lookup, and media that is already local resolves straight from its URL, so no
4162 + // image is fetched or stored twice.
4163 + $content = $this->wdkit_relink_gutenberg_content( $content );
4164 +
4165 + $result = wp_update_post(
4166 + array(
4167 + 'ID' => $post_id,
4168 + 'post_content' => wp_slash( $content ),
4169 + ),
4170 + true
4171 + );
4172 +
4173 + if ( is_wp_error( $result ) ) {
4174 + return array(
4175 + 'success' => false,
4176 + 'message' => $result->get_error_message(),
4177 + );
4178 + }
4179 +
4180 + self::wdkit_rebuild_block_css( $post_id );
4181 + } else {
4182 + $elements = isset( $_POST['content'] ) ? json_decode( wp_unslash( $_POST['content'] ), true ) : array();
4183 +
4184 + if ( ! class_exists( '\\Elementor\\Plugin' ) ) {
4185 + return array(
4186 + 'success' => false,
4187 + 'message' => esc_html__( 'Elementor not available', 'wdesignkit' ),
4188 + );
4189 + }
4190 +
4191 + $document = \Elementor\Plugin::$instance->documents->get( $post_id );
4192 + if ( ! $document ) {
4193 + return array(
4194 + 'success' => false,
4195 + 'message' => esc_html__( 'Elementor document not found', 'wdesignkit' ),
4196 + );
4197 + }
4198 +
4199 + // Same as wkit_update_elementor_template(): browser-posted content keeps the
4200 + // source template's attachment IDs, so repair them or this save undoes the
4201 + // create-time fix and background images stop rendering.
4202 + $elements = self::wdkit_repair_attachment_ids( $elements );
4203 +
4204 + $document->save( array( 'elements' => $elements ) );
4205 + }
4206 +
4207 + return array(
4208 + 'success' => true,
4209 + 'message' => esc_html__( 'Page content updated', 'wdesignkit' ),
4210 + );
4211 + }
4212 +
4213 + /**
3112 4214 * Import single template and section from plugin only
3113 4215 *
3114 4216 * @param array $args store data.
3115 4217 * @param array $template_id store data.
@@ -3117,8 +4219,21 @@
3117 4219 * @param array $temp_data store data.
3118 4220 * */
3119 4221 protected function import_page_section_content() {
3120 4222
4223 + // Elementor sideloads every image referenced by the page from inside this request.
4224 + // A single oversized source image decodes to more than the whole memory limit, so
4225 + // guard before any of that starts.
4226 + $this->wdkit_guard_oversized_images();
4227 +
4228 + // Sideloading images for image-heavy pages (wdkit_media_import → Imagick
4229 + // thumbnail generation per image) can exceed the default 30s execution
4230 + // limit and fatal the request mid-import. Give this single page import
4231 + // more headroom; harmless no-op where set_time_limit() is disabled.
4232 + if ( function_exists( 'set_time_limit' ) ) {
4233 + @set_time_limit( 120 );
4234 + }
4235 +
3121 4236 if ( isset( $_POST['args'] ) ) {
3122 4237 $args = ! empty( $_POST['args'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['args'] ) ), true ) : array();
3123 4238 }
3124 4239
@@ -3141,11 +4256,9 @@
3141 4256 if ( isset( $_POST['template_id'] ) ) {
3142 4257 $template_id = ! empty( $_POST['template_id'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['template_id'] ), true ) ) : '';
3143 4258 }
3144 4259
3145 - if ( isset( $_POST['temp_type'] ) ) {
3146 - $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'normal';
3147 - }
4260 + $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'normal';
3148 4261
3149 4262 if ( isset( $_POST['data'] ) ) {
3150 4263 $data = ! empty( $_POST['data'] ) ? json_decode( wp_unslash( $_POST['data'] ) ) : '';
3151 4264 }
@@ -3166,8 +4279,21 @@
3166 4279 }
3167 4280
3168 4281 if ( ! empty( $data ) && ! empty( $template_id ) && ! empty( $post_type ) && current_user_can( 'manage_options' ) ) {
3169 4282 $post_content = $data;
4283 + // Restore The Plus Addons' globals before the page is built, so the widgets'
4284 + // tp_global_preset references resolve as soon as it renders. Done here rather
4285 + // than in the save-template UI's confirmation dialog so that every import path
4286 + // - the library, the abilities, the theme builder - gets it.
4287 + if ( isset( $post_content->tp_globals ) && ! empty( $post_content->tp_globals ) ) {
4288 + $this->wdkit_merge_tp_globals(
4289 + json_decode( wp_json_encode( $post_content->tp_globals ), true ),
4290 + isset( $post_content->tp_global_refs )
4291 + ? json_decode( wp_json_encode( $post_content->tp_global_refs ), true )
4292 + : array()
4293 + );
4294 + }
4295 +
3170 4296 $post_title = isset( $post_content->title ) ? sanitize_text_field( $post_content->title ) : '';
3171 4297 $post_slug = isset( $post_content->slug ) ? sanitize_text_field( $post_content->slug ) : '';
3172 4298 $file_type = isset( $post_content->file_type ) ? sanitize_text_field( $post_content->file_type ) : '';
3173 4299 $content = isset( $post_content->content ) ? wp_slash( $post_content->content ) : '';
@@ -3177,9 +4303,9 @@
3177 4303 if ( empty( $content ) ) {
3178 4304 wp_send_json(
3179 4305 array(
3180 4306 'template_id' => $template_id,
3181 - 'message' => 'Content is Empty.',
4307 + 'message' => __( 'Content is Empty.', 'wdesignkit' ),
3182 4308 )
3183 4309 );
3184 4310 wp_die();
3185 4311 } elseif ( ! empty( $content ) && ! empty( $file_type ) && 'wp_block' === $file_type ) {
@@ -3185,27 +4311,17 @@
3185 4311 } elseif ( ! empty( $content ) && ! empty( $file_type ) && 'wp_block' === $file_type ) {
3186 4312
3187 4313 $editor = ( 'wdkit' === $args['editor'] ) ? 'gutenberg' : $args['editor'];
3188 4314 $blocks = parse_blocks( stripslashes( $content ) );
3189 -
4315 +
3190 4316 $blocks = $this->wdkit_media_import( $blocks, $editor );
3191 4317
3192 4318 $processor = new WDKIT_Nexter_Block_Processor();
3193 4319 $blocks = $processor->run( $blocks );
3194 4320 $content = serialize_blocks( $blocks );
3195 -
4321 +
3196 4322 $content = $this->replace_unicode_glitch( serialize_blocks( $blocks ) );
3197 4323
3198 - if ( ! empty( $category_list ) && is_array( $category_list ) ) {
3199 - $category_ids = array_map( 'intval', $category_list );
3200 - wp_set_post_terms( $inserted_id, $category_ids, 'category' );
3201 - }
3202 -
3203 - if ( ! empty( $tag_list ) && is_array( $tag_list ) ) {
3204 - $tag_ids = array_map( 'intval', $tag_list );
3205 - wp_set_post_terms( $inserted_id, $tag_ids, 'post_tag' );
3206 - }
3207 -
3208 4324 $inserted_post = wp_insert_post(
3209 4325 array(
3210 4326 'post_status' => 'publish',
3211 4327 'post_type' => $post_type,
@@ -3224,9 +4340,9 @@
3224 4340 );
3225 4341 wp_die();
3226 4342 }
3227 4343
3228 - if ( ! empty( $thumb_image ) ) {
4344 + if ( ! empty( $thumb_image ) && wdesignkit_validate_external_url( $thumb_image ) ) {
3229 4345 // $featured_image_url = esc_url_raw( $thumb_image );
3230 4346 $tmp = download_url( $thumb_image );
3231 4347 if ( is_wp_error( $tmp ) ) {
3232 4348 error_log( 'Image download failed: ' . esc_html( $tmp->get_error_message() ) );
@@ -3262,9 +4378,9 @@
3262 4378 $custom_meta = isset( $post_content->custom_meta ) ? json_decode( wp_json_encode( $post_content->custom_meta ), true ) : '';
3263 4379 if ( ! empty( $custom_meta ) ) {
3264 4380 foreach ( $custom_meta as $meta_key => $meta_val ) {
3265 4381 if ( isset( $meta_val[0] ) && ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
3266 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
4382 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
3267 4383 }
3268 4384
3269 4385 if ( '' === get_post_meta( $inserted_post, $meta_key, true ) && isset( $meta_val[0] ) ) {
3270 4386 add_post_meta( $inserted_post, $meta_key, $meta_val[0] );
@@ -3296,13 +4412,20 @@
3296 4412 Tpgb_Library()->remove_backend_dir_files();
3297 4413 }
3298 4414
3299 4415 clean_post_cache( $inserted_post );
4416 +
4417 + // This whole method imports exactly one section per call — unlike
4418 + // wdkit_import_template()/wdkit_import_kit_template(), it never fired this hook
4419 + // at all, so single-section imports (Header/Footer/CTA/etc., a primary import
4420 + // path per the Template Type sidebar) were invisible to tracking entirely.
4421 + do_action( 'wdkit_template_imported', 'single', sanitize_key( $editor ), 1 );
4422 +
3300 4423 wp_send_json(
3301 4424 array(
3302 4425 $temp_id => $temp_detail,
3303 4426 'description' => 'Yay! Your Section has been Successfully Imported.',
3304 - 'message' => 'Successfully Imported.',
4427 + 'message' => __( 'Successfully Imported.', 'wdesignkit' ),
3305 4428 'inserted_id' => $inserted_post,
3306 4429 'success' => true,
3307 4430 )
3308 4431 );
@@ -3313,9 +4436,9 @@
3313 4436 if ( empty( $content ) ) {
3314 4437 wp_send_json(
3315 4438 array(
3316 4439 'template_id' => $template_id,
3317 - 'message' => 'Content is Empty.',
4440 + 'message' => __( 'Content is Empty.', 'wdesignkit' ),
3318 4441 )
3319 4442 );
3320 4443 wp_die();
3321 4444 } elseif ( ! empty( $content ) && ! empty( $file_type ) && 'elementor' === $file_type ) {
@@ -3353,9 +4476,9 @@
3353 4476 }
3354 4477
3355 4478 $inserted_id = $new_document->get_main_id();
3356 4479
3357 - if ( ! empty( $thumb_image ) ) {
4480 + if ( ! empty( $thumb_image ) && wdesignkit_validate_external_url( $thumb_image ) ) {
3358 4481 // $featured_image_url = esc_url_raw( $thumb_image );
3359 4482 $tmp = download_url( $thumb_image );
3360 4483 if ( is_wp_error( $tmp ) ) {
3361 4484 error_log( 'Image download failed: ' . esc_html( $tmp->get_error_message() ) );
@@ -3409,9 +4532,9 @@
3409 4532 $custom_meta = isset( $post_content->custom_meta ) ? json_decode( wp_json_encode( $post_content->custom_meta ), true ) : '';
3410 4533 if ( ! empty( $custom_meta ) ) {
3411 4534 foreach ( $custom_meta as $meta_key => $meta_val ) {
3412 4535 if ( ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
3413 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
4536 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
3414 4537 }
3415 4538 if ( '' === get_post_meta( $inserted_id, $meta_key, true ) ) {
3416 4539 add_post_meta( $inserted_id, $meta_key, $meta_val[0] );
3417 4540 }
@@ -3435,14 +4558,18 @@
3435 4558 }
3436 4559
3437 4560 \Elementor\Plugin::$instance->files_manager->clear_cache();
3438 4561
4562 + // See the matching note in the Gutenberg branch above — this method never
4563 + // fired the tracking hook for either editor.
4564 + do_action( 'wdkit_template_imported', 'single', 'elementor', 1 );
4565 +
3439 4566 wp_send_json(
3440 4567 array(
3441 4568 $temp_id => $temp_detail,
3442 4569 'content' => $temp_con,
3443 4570 'description' => 'Yay! Your Section has been Successfully Imported.',
3444 - 'message' => 'Successfully Imported.',
4571 + 'message' => __( 'Successfully Imported.', 'wdesignkit' ),
3445 4572 'inserted_id' => $inserted_id,
3446 4573 'success' => true,
3447 4574 )
3448 4575 );
@@ -3572,8 +4699,12 @@
3572 4699 }
3573 4700
3574 4701 $image_url = esc_url_raw( $_POST['image_url'] );
3575 4702
4703 + if ( ! wdesignkit_validate_external_url( $image_url ) ) {
4704 + wp_send_json_error( 'Image could not be downloaded.' );
4705 + }
4706 +
3576 4707 $tmp_file = download_url( $image_url );
3577 4708 if ( is_wp_error( $tmp_file ) ) {
3578 4709 wp_send_json_error( 'Image could not be downloaded.' );
3579 4710 }
@@ -3609,9 +4740,21 @@
3609 4740
3610 4741 $upload_dir = wp_upload_dir();
3611 4742 $result_urls = array();
3612 4743
4744 + $colour_index = 0;
3613 4745 foreach ( $img_colors as $name => $rgb ) {
4746 + // $name is a key from the posted colours payload and went straight into the output
4747 + // filename, so traversal sequences in it steered imagepng() outside the upload
4748 + // directory (CWE-22, ClickUp 86d41ced6). sanitize_file_name() flattens it to one
4749 + // path segment; a key made only of dots/separators sanitizes to empty, so fall back
4750 + // to a positional index rather than writing to a bare "colored--<time>.png".
4751 + ++$colour_index;
4752 + $safe_name = sanitize_file_name( (string) $name );
4753 + if ( '' === $safe_name ) {
4754 + $safe_name = 'colour-' . $colour_index;
4755 + }
4756 +
3614 4757 $new = imagecreatetruecolor( $width, $height );
3615 4758 imagesavealpha( $new, true );
3616 4759 imagealphablending( $new, false );
3617 4760
@@ -3637,9 +4780,9 @@
3637 4780 imagesetpixel( $new, $x, $y, $color );
3638 4781 }
3639 4782 }
3640 4783
3641 - $filename = 'colored-' . $name . '-' . time() . '.png';
4784 + $filename = 'colored-' . $safe_name . '-' . time() . '.png';
3642 4785 $filepath = $upload_dir['path'] . '/' . $filename;
3643 4786
3644 4787 imagepng( $new, $filepath );
3645 4788 imagedestroy( $new );
@@ -3855,8 +4998,13 @@
3855 4998 $page_information = isset( $_POST['page_information'] ) ? sanitize_text_field( wp_unslash( $_POST['page_information'] ) ) : '';
3856 4999 $page_information = json_decode( $page_information, true );
3857 5000
3858 5001 if ( ! empty( $page_information ) && is_array( $page_information ) ) {
5002 +
5003 + // Every page and attachment now exists, so resolve any image ID the per-page
5004 + // pass could not (siblings import concurrently and share icons).
5005 + $this->wdkit_sweep_attachment_ids( wp_list_pluck( $page_information, 'inserted_id' ) );
5006 +
3859 5007 // Step 1: banavo mapping [ old_id => new_id ]
3860 5008 $id_mapping = array();
3861 5009 foreach ( $page_information as $page_info ) {
3862 5010 if ( ! empty( $page_info['old_page_id'] ) ) {
@@ -3949,12 +5097,73 @@
3949 5097 }
3950 5098
3951 5099 $response = json_decode( wp_json_encode( $response['data'] ), true );
3952 5100
5101 + $this->wdkit_cache_cloud_usage( $response );
5102 +
3953 5103 wp_send_json( $response );
3954 5104 wp_die();
3955 5105 }
3956 5106
5107 + /**
5108 + * Caches the storage / credit figures this response carried.
5109 + *
5110 + * This handler is the ONLY place those numbers ever exist on the site: the cloud endpoint
5111 + * authenticates with a user token that only a logged-in dashboard request carries, so the
5112 + * analytics heartbeat — which runs on cron with no user at all — can never fetch them itself.
5113 + * Caching them here is what lets Posimyth_Tracker_WDK report them, and it reports the cache's
5114 + * age alongside so a stale reading is recognisable as one.
5115 + *
5116 + * Field names are probed rather than assumed: the cloud has renamed these before, and the
5117 + * licence ability already carries six spellings of its own key field for the same reason. An
5118 + * unrecognised shape simply caches nothing rather than storing a wrong number.
5119 + *
5120 + * Only the figures are kept. No token, no account id, no email — the analytics consent copy
5121 + * promises non-sensitive data only, and this is read by the payload builder.
5122 + *
5123 + * @since 2.6.4
5124 + *
5125 + * @param mixed $data Decoded `data` object from the credits endpoint.
5126 + * @return void
5127 + */
5128 + private function wdkit_cache_cloud_usage( $data ) {
5129 + if ( ! is_array( $data ) ) {
5130 + return;
5131 + }
5132 +
5133 + $pick = static function ( $source, array $fields ) {
5134 + foreach ( $fields as $field ) {
5135 + if ( isset( $source[ $field ] ) && is_numeric( $source[ $field ] ) ) {
5136 + return (float) $source[ $field ];
5137 + }
5138 + }
5139 + return null;
5140 + };
5141 +
5142 + $usage = array(
5143 + 'storage_used' => $pick( $data, array( 'used_storage', 'storage_used', 'used_space' ) ),
5144 + 'storage_total' => $pick( $data, array( 'total_storage', 'storage_total', 'storage', 'total_space' ) ),
5145 + 'credit_used' => $pick( $data, array( 'used_credit', 'credit_used', 'used_credits' ) ),
5146 + 'credit_total' => $pick( $data, array( 'total_credit', 'credit_total', 'credits', 'real_credit' ) ),
5147 + );
5148 +
5149 + $usage = array_filter(
5150 + $usage,
5151 + static function ( $value ) {
5152 + return null !== $value;
5153 + }
5154 + );
5155 +
5156 + if ( empty( $usage ) ) {
5157 + return;
5158 + }
5159 +
5160 + $usage['cached_at'] = gmdate( 'Y-m-d H:i:s' );
5161 +
5162 + // Not autoloaded: read once a week by the heartbeat, never on a front-end request.
5163 + update_option( 'wdkit_cloud_usage', $usage, false );
5164 + }
5165 +
3957 5166 public function wdkit_nxt_thembuilder_reset() {
3958 5167 $post_id = isset( $_POST['post_id'] ) ? sanitize_text_field( $_POST['post_id'] ) : '';
3959 5168 $sections_layout = get_post_meta( $post_id, 'nxt-hooks-layout-sections', true );
3960 5169
@@ -4066,9 +5275,10 @@
4066 5275 protected function wdkit_activate_key() {
4067 5276 $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
4068 5277 $response = '';
4069 5278
4070 - if ( empty( $user_email ) ) {
5279 + // Bug C fix: variable was $user_email but only $email is set above — always triggered empty() guard.
5280 + if ( empty( $email ) ) {
4071 5281 $response = array(
4072 5282 'message' => $this->e_msg_login,
4073 5283 'description' => $this->e_desc_login,
4074 5284 'success' => false,
@@ -4286,10 +5496,35 @@
4286 5496 'description' => esc_html__( 'widget JSON file not found.', 'wdesignkit' ),
4287 5497 );
4288 5498 }
4289 5499
4290 - $json_path = WDKIT_BUILDER_PATH . "/{$widget_type}/{$folder_name}/{$file_name}";
5500 + // Read-side twin of the write and delete traversals fixed in 86d41cckh / 86d41ccz2: all
5501 + // three segments arrive from $_POST with only wp_unslash() applied — which strips
5502 + // nothing path-relevant — so "../" in any of them walked out of the builder directory
5503 + // and this handler returned the decoded contents of any .json file the web server user
5504 + // could read (CWE-22, ClickUp 86d41zaun).
5505 + $safe_path = wdesignkit_widget_path_guard( $widget_type, $folder_name, $file_name );
4291 5506
5507 + if ( false === $safe_path || '' === $safe_path['folder'] || '' === $safe_path['file'] ) {
5508 + return array(
5509 + 'success' => false,
5510 + 'message' => esc_html__( 'Widget JSON not found', 'wdesignkit' ),
5511 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
5512 + );
5513 + }
5514 +
5515 + $json_path = $safe_path['base'];
5516 +
5517 + // Re-check the resolved file: the component guard above cannot see a symlink. Returns
5518 + // false for a path that does not exist, which is the same answer we want anyway.
5519 + if ( ! wdesignkit_path_inside_builder_dir( "$json_path.json" ) ) {
5520 + return array(
5521 + 'success' => false,
5522 + 'message' => esc_html__( 'Widget JSON not found', 'wdesignkit' ),
5523 + 'description' => esc_html__( 'widget JSON file not found.', 'wdesignkit' ),
5524 + );
5525 + }
5526 +
4292 5527 $json_data = wp_json_file_decode( "$json_path.json" );
4293 5528 if ( ! empty( $json_data ) ) {
4294 5529 $result = (object) array(
4295 5530 'success' => true,
@@ -4315,9 +5550,9 @@
4315 5550 *
4316 5551 * @since 1.0.0
4317 5552 */
4318 5553 protected function wdkit_download_widget() {
4319 - $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_sanitizer_bypass( $_POST, 'widget_info', 'none' ) : '';
5554 + $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_extract_post_field( $_POST, 'widget_info', 'none' ) : '';
4320 5555 $data = json_decode( stripslashes( $data ) );
4321 5556
4322 5557 $array_data = array(
4323 5558 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
@@ -4322,8 +5557,10 @@
4322 5557 $array_data = array(
4323 5558 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
4324 5559 'type' => isset( $data->type ) ? sanitize_text_field( $data->type ) : '',
4325 5560 'w_unique' => isset( $data->w_uniq ) ? sanitize_text_field( $data->w_uniq ) : '',
5561 + // Bug F fix: u_id (widget owner's user ID) was missing — cloud cannot locate the widget without it.
5562 + 'u_id' => isset( $data->u_id ) ? sanitize_text_field( $data->u_id ) : '',
4326 5563 );
4327 5564
4328 5565 $response = $this->wkit_api_call( $array_data, 'save_widget' );
4329 5566 $success = ! empty( $response['success'] ) ? $response['success'] : false;
@@ -4358,10 +5595,11 @@
4358 5595
4359 5596 $img_url = ! empty( $response['data']['image'] ) ? $response['data']['image'] : '';
4360 5597 $json_data = ! empty( $response['data']['json'] ) ? json_decode( $response['data']['json'], true ) : '';
4361 5598
5599 + // Bug E fix (part 1): $responce was a typo of $response — sent undefined variable (null) to frontend.
4362 5600 if ( empty( $response['success'] ) ) {
4363 - wp_send_json( $responce );
5601 + wp_send_json( $response );
4364 5602 wp_die();
4365 5603 }
4366 5604
4367 5605 if ( empty( $img_url ) && empty( $json_data ) ) {
@@ -4382,14 +5620,34 @@
4382 5620 if ( ! is_array( $json_data ) ) {
4383 5621 $json_data = json_decode( $json_data, true );
4384 5622 }
4385 5623
4386 - $title = ! empty( $json_data['widget_data']['widgetdata']['name'] ) ? sanitize_text_field( $json_data['widget_data']['widgetdata']['name'] ) : '';
4387 - $builder = ! empty( $json_data['widget_data']['widgetdata']['type'] ) ? sanitize_text_field( $json_data['widget_data']['widgetdata']['type'] ) : '';
4388 - $w_uniq = ! empty( $json_data['widget_data']['widgetdata']['widget_id'] ) ? sanitize_text_field( $json_data['widget_data']['widgetdata']['widget_id'] ) : '';
5624 + // Sanitize as filenames before use in the widget path (CWE-22): sanitize_file_name()
5625 + // on name/id and sanitize_key() + allowlist on the builder strip path separators and
5626 + // dots so a crafted cloud response cannot escape WDKIT_BUILDER_PATH.
5627 + $title = ! empty( $json_data['widget_data']['widgetdata']['name'] ) ? sanitize_file_name( $json_data['widget_data']['widgetdata']['name'] ) : '';
5628 + $builder = ! empty( $json_data['widget_data']['widgetdata']['type'] ) ? sanitize_key( $json_data['widget_data']['widgetdata']['type'] ) : '';
5629 + $w_uniq = ! empty( $json_data['widget_data']['widgetdata']['widget_id'] ) ? sanitize_file_name( $json_data['widget_data']['widgetdata']['widget_id'] ) : '';
4389 5630
4390 - $folder_name = str_replace( ' ', '-', $title ) . '_' . $w_uniq;
4391 - $file_name = str_replace( ' ', '_', $title ) . '_' . $w_uniq;
5631 + $allowed_builders = array( 'elementor', 'gutenberg', 'gutenberg_core', 'bricks' );
5632 + if ( '' === $title || '' === $w_uniq || ! in_array( $builder, $allowed_builders, true ) ) {
5633 + $responce = (object) array(
5634 + 'success' => false,
5635 + 'message' => esc_html__( 'Operation Failed!', 'wdesignkit' ),
5636 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
5637 + );
5638 +
5639 + wp_send_json( $responce );
5640 + wp_die();
5641 + }
5642 +
5643 + // Canonical helpers replace spaces BEFORE sanitize_file_name(). $title above is
5644 + // already sanitized, which collapsed spaces to hyphens and left the underscore pass
5645 + // with nothing to do — a multi-word title wrote "My-Widget_id.json" next to the
5646 + // "My_Widget_id.php" the builder's save path writes. The loader pairs the two by
5647 + // swapping .php for .json, so the widget was silently dropped (ClickUp 86d41cck5).
5648 + $folder_name = wdesignkit_widget_folder_name( $title, $w_uniq );
5649 + $file_name = wdesignkit_widget_file_name( $title, $w_uniq );
4392 5650 $builder_type_path = WDKIT_BUILDER_PATH . "/{$builder}/";
4393 5651
4394 5652 if ( ! is_dir( $builder_type_path ) ) {
4395 5653 wp_mkdir_p( $builder_type_path );
@@ -4399,17 +5657,30 @@
4399 5657 wp_mkdir_p( $builder_type_path . $folder_name );
4400 5658 }
4401 5659
4402 5660 if ( ! empty( $img_url ) ) {
4403 - $img_body = wp_remote_get( $img_url );
4404 - $img_ext = pathinfo( $img_url )['extension'];
5661 + // SSRF guard (CWE-918): validate the resolved host before fetching.
5662 + $img_body = wdesignkit_safe_remote_get( $img_url );
5663 + if ( ! is_wp_error( $img_body ) ) {
5664 + // The remote extension was written verbatim here, so a cloud response naming a
5665 + // ".php" image put executable PHP in the builder directory (CWE-434,
5666 + // ClickUp 86d41cczd). An empty return means the bytes are not an image.
5667 + $img_ext = wdesignkit_safe_image_extension( $img_url, $img_body['body'] );
4405 5668
4406 - $wp_filesystem->put_contents( WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name.$img_ext", $img_body['body'] );
4407 - $json_data['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
5669 + if ( '' !== $img_ext ) {
5670 + $wp_filesystem->put_contents( WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name.$img_ext", $img_body['body'] );
5671 + $json_data['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
5672 + }
5673 + }
4408 5674 }
4409 5675
5676 + if ( function_exists( 'wdesignkit_invalidate_widget_registry' ) ) {
5677 + wdesignkit_invalidate_widget_registry( $builder );
5678 + }
5679 +
5680 + // Bug E fix (part 2): success was hardcoded false on the successful download path — always reported failure.
4410 5681 $result = (object) array(
4411 - 'success' => false,
5682 + 'success' => true,
4412 5683 'message' => ! empty( $response['message'] ) ? $response['message'] : esc_html__( 'no message', 'wdesignkit' ),
4413 5684 'description' => '',
4414 5685 'json' => wp_json_encode( $json_data ),
4415 5686 );
@@ -4424,9 +5695,9 @@
4424 5695 *
4425 5696 * @since 1.0.0
4426 5697 */
4427 5698 protected function wdkit_add_widget() {
4428 - $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_sanitizer_bypass( $_POST, 'widget_info', 'none' ) : '';
5699 + $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_extract_post_field( $_POST, 'widget_info', 'none' ) : '';
4429 5700 $data = base64_decode( $data );
4430 5701 $data = json_decode( $data );
4431 5702
4432 5703 $title = isset( $data->title ) ? sanitize_text_field( $data->title ) : '';
@@ -4435,9 +5706,11 @@
4435 5706 $w_image = isset( $data->w_image ) ? esc_url_raw( $data->w_image ) : '';
4436 5707
4437 5708 if ( ! empty( $w_image ) ) {
4438 5709 $w_image = str_replace( '\\', '', $w_image );
4439 - $w_image = wp_remote_get( $w_image )['body'];
5710 + // SSRF guard (CWE-918): validate the resolved host before fetching.
5711 + $fetched = wdesignkit_safe_remote_get( $w_image );
5712 + $w_image = is_wp_error( $fetched ) ? '' : wp_remote_retrieve_body( $fetched );
4440 5713 }
4441 5714
4442 5715 $array_data = array(
4443 5716 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
@@ -4477,22 +5750,51 @@
4477 5750 $img_url = ! empty( $response['data']['imgurl'] ) ? $response['data']['imgurl'] : '';
4478 5751
4479 5752 if ( ! empty( $img_url ) && 'error' !== $res ) {
4480 5753
4481 - $img_body = wp_remote_get( $img_url );
4482 - $img_ext = pathinfo( $img_url )['extension'];
4483 - include_once ABSPATH . 'wp-admin/includes/file.php';
4484 - \WP_Filesystem();
4485 - global $wp_filesystem;
4486 - $folder_name = str_replace( ' ', '-', $title ) . '_' . $w_uniq;
4487 - $file_name = str_replace( ' ', '_', $title ) . '_' . $w_uniq;
4488 - $file_path = WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name";
5754 + // SSRF guard (CWE-918): validate the resolved host before fetching.
5755 + $img_body = wdesignkit_safe_remote_get( $img_url );
5756 + if ( ! is_wp_error( $img_body ) ) {
5757 + // Verified against the payload rather than trusted from the URL (CWE-434,
5758 + // ClickUp 86d41cczd); '' means the bytes are not an image we accept.
5759 + $img_ext = wdesignkit_safe_image_extension( $img_url, $img_body['body'] );
5760 + include_once ABSPATH . 'wp-admin/includes/file.php';
5761 + \WP_Filesystem();
5762 + global $wp_filesystem;
5763 + // Canonical helpers, so the JSON read and the image write here address the same
5764 + // base name every other writer uses (ClickUp 86d41cck5). They also apply
5765 + // sanitize_file_name(), which $title and $w_uniq had not been through.
5766 + $folder_name = wdesignkit_widget_folder_name( $title, $w_uniq );
5767 + $file_name = wdesignkit_widget_file_name( $title, $w_uniq );
4489 5768
4490 - $u_r_l = wp_json_file_decode( "$file_path.json" );
4491 - $u_r_l->widget_data->widgetdata->w_image = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
5769 + // $builder reaches here with only sanitize_text_field() applied and no
5770 + // allowlist, so it was a live traversal segment in this path (CWE-22,
5771 + // ClickUp 86d41cckh). Unlike the download handler earlier in this file, this
5772 + // one had neither the builder allowlist nor a containment check.
5773 + $safe_path = wdesignkit_widget_path_guard( $builder, $folder_name, $file_name );
5774 + if ( false === $safe_path || ! wdesignkit_path_inside_builder_dir( $safe_path['dir'] ) ) {
5775 + wp_send_json(
5776 + (object) array(
5777 + 'success' => false,
5778 + 'message' => esc_html__( 'Operation Failed!', 'wdesignkit' ),
5779 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
5780 + )
5781 + );
5782 + wp_die();
5783 + }
4492 5784
4493 - $wp_filesystem->put_contents( "$file_path.json", wp_json_encode( $u_r_l ) );
4494 - $wp_filesystem->put_contents( "$file_path.$img_ext", $img_body['body'] );
5785 + $builder = $safe_path['builder'];
5786 + $file_path = $safe_path['base'];
5787 +
5788 + $u_r_l = wp_json_file_decode( "$file_path.json" );
5789 +
5790 + if ( '' !== $img_ext ) {
5791 + $u_r_l->widget_data->widgetdata->w_image = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
5792 + $wp_filesystem->put_contents( "$file_path.$img_ext", $img_body['body'] );
5793 + }
5794 +
5795 + $wp_filesystem->put_contents( "$file_path.json", wp_json_encode( $u_r_l ) );
5796 + }
4495 5797 }
4496 5798
4497 5799 wp_send_json( $response );
4498 5800 wp_die();
@@ -4587,18 +5889,22 @@
4587 5889
4588 5890 $get_setting = get_option( 'wkit_settings_panel', false );
4589 5891
4590 5892 $setting_data = array(
4591 - 'builder' => isset( $get_setting['builder'] ) ? $get_setting['builder'] : true,
4592 - 'template' => isset( $get_setting['template'] ) ? $get_setting['template'] : true,
4593 - 'gutenberg_builder' => isset( $get_setting['gutenberg_builder'] ) ? $get_setting['gutenberg_builder'] : true,
4594 - 'gutenberg_core_builder' => isset( $get_setting['gutenberg_core_builder'] ) ? $get_setting['gutenberg_core_builder'] : false,
4595 - 'elementor_builder' => isset( $get_setting['elementor_builder'] ) ? $get_setting['elementor_builder'] : true,
4596 - 'bricks_builder' => isset( $get_setting['bricks_builder'] ) ? $get_setting['bricks_builder'] : false,
4597 - 'gutenberg_template' => isset( $get_setting['gutenberg_template'] ) ? $get_setting['gutenberg_template'] : true,
4598 - 'elementor_template' => isset( $get_setting['elementor_template'] ) ? $get_setting['elementor_template'] : true,
4599 - 'code_snippet' => isset( $get_setting['code_snippet'] ) ? $get_setting['code_snippet'] : true,
4600 - 'plugin_version' => $version_check,
5893 + 'builder' => isset( $get_setting['builder'] ) ? $get_setting['builder'] : true,
5894 + 'template' => isset( $get_setting['template'] ) ? $get_setting['template'] : true,
5895 + 'gutenberg_builder' => isset( $get_setting['gutenberg_builder'] ) ? $get_setting['gutenberg_builder'] : true,
5896 + 'gutenberg_core_builder' => isset( $get_setting['gutenberg_core_builder'] ) ? $get_setting['gutenberg_core_builder'] : false,
5897 + 'elementor_builder' => isset( $get_setting['elementor_builder'] ) ? $get_setting['elementor_builder'] : true,
5898 + 'bricks_builder' => isset( $get_setting['bricks_builder'] ) ? $get_setting['bricks_builder'] : true,
5899 + 'gutenberg_template' => isset( $get_setting['gutenberg_template'] ) ? $get_setting['gutenberg_template'] : true,
5900 + 'elementor_template' => isset( $get_setting['elementor_template'] ) ? $get_setting['elementor_template'] : true,
5901 + 'code_snippet' => isset( $get_setting['code_snippet'] ) ? $get_setting['code_snippet'] : true,
5902 + 'cross_copy_paste' => isset( $get_setting['cross_copy_paste'] ) ? $get_setting['cross_copy_paste'] : false,
5903 + 'cross_copy_paste_elementor' => isset( $get_setting['cross_copy_paste_elementor'] ) ? $get_setting['cross_copy_paste_elementor'] : false,
5904 + 'cross_copy_paste_gutenberg' => isset( $get_setting['cross_copy_paste_gutenberg'] ) ? $get_setting['cross_copy_paste_gutenberg'] : false,
5905 + 'cross_copy_paste_bricks' => isset( $get_setting['cross_copy_paste_bricks'] ) ? $get_setting['cross_copy_paste_bricks'] : false,
5906 + 'plugin_version' => $version_check,
4601 5907 );
4602 5908
4603 5909 if ( isset( $get_setting['remove_db'] ) ) {
4604 5910 $setting_data['remove_db'] = $get_setting['remove_db'];
@@ -4651,9 +5957,9 @@
4651 5957 }
4652 5958
4653 5959 $get_updated_data = get_option( 'wkit_white_label', false );
4654 5960 $response = array(
4655 - 'message' => 'Data Added successfully',
5961 + 'message' => __( 'Data Added successfully', 'wdesignkit' ),
4656 5962 'success' => true,
4657 5963 'data' => $get_updated_data,
4658 5964 );
4659 5965
@@ -4700,21 +6006,21 @@
4700 6006 if ( ! empty( $response['data'] ) ) {
4701 6007 $response = json_decode( wp_json_encode( $response['data'] ), true );
4702 6008
4703 6009 if ( ! empty( $response['data']['tpae_licence'] ) && is_serialized( $response['data']['tpae_licence'] ) ) {
4704 - $response['data']['tpae_licence'] = unserialize( $response['data']['tpae_licence'] );
6010 + $response['data']['tpae_licence'] = unserialize( $response['data']['tpae_licence'], array( 'allowed_classes' => false ) );
4705 6011 }
4706 6012
4707 6013 if ( ! empty( $response['data']['tpag_licence'] ) && is_serialized( $response['data']['tpag_licence'] ) ) {
4708 - $response['data']['tpag_licence'] = unserialize( $response['data']['tpag_licence'] );
6014 + $response['data']['tpag_licence'] = unserialize( $response['data']['tpag_licence'], array( 'allowed_classes' => false ) );
4709 6015 }
4710 6016
4711 6017 if ( ! empty( $response['data']['uichemy_licence'] ) && is_serialized( $response['data']['uichemy_licence'] ) ) {
4712 - $response['data']['uichemy_licence'] = unserialize( $response['data']['uichemy_licence'] );
6018 + $response['data']['uichemy_licence'] = unserialize( $response['data']['uichemy_licence'], array( 'allowed_classes' => false ) );
4713 6019 }
4714 6020
4715 6021 if ( ! empty( $response['data']['wdkit_licence'] ) && is_serialized( $response['data']['wdkit_licence'] ) ) {
4716 - $response['data']['wdkit_licence'] = unserialize( $response['data']['wdkit_licence'] );
6022 + $response['data']['wdkit_licence'] = unserialize( $response['data']['wdkit_licence'], array( 'allowed_classes' => false ) );
4717 6023
4718 6024 // Store WDesignKit license status locally for quick access
4719 6025 if ( ! empty( $response['data']['wdkit_licence'] ) && is_array( $response['data']['wdkit_licence'] ) ) {
4720 6026 update_option( 'wdkit_licence_data', $response['data']['wdkit_licence'] );
@@ -4721,9 +6027,9 @@
4721 6027 }
4722 6028 }
4723 6029
4724 6030 if ( ! empty( $response['data']['wdkit_licence_extra'] ) && is_serialized( $response['data']['wdkit_licence_extra'] ) ) {
4725 - $response['data']['wdkit_licence_extra'] = unserialize( $response['data']['wdkit_licence_extra'] );
6031 + $response['data']['wdkit_licence_extra'] = unserialize( $response['data']['wdkit_licence_extra'], array( 'allowed_classes' => false ) );
4726 6032 }
4727 6033 }
4728 6034
4729 6035 wp_send_json( $response );
@@ -4766,12 +6072,16 @@
4766 6072 */
4767 6073 protected function wdkit_sync_licence_key() {
4768 6074 $token = ! empty( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
4769 6075 $licencename = ! empty( $_POST['licencename'] ) ? sanitize_text_field( wp_unslash( $_POST['licencename'] ) ) : '';
6076 + // Needed to identify which extra-credit key to sync (wdkit_extra / wdkit_ai_extra
6077 + // are arrays matched by the api key's last digits on the server).
6078 + $apikey = ! empty( $_POST['apikey'] ) ? sanitize_text_field( wp_unslash( $_POST['apikey'] ) ) : '';
4770 6079
4771 6080 $args = array(
4772 6081 'token' => $token,
4773 6082 'licencename' => $licencename,
6083 + 'apikey' => $apikey,
4774 6084 );
4775 6085
4776 6086 $response = $this->wkit_api_call( $args, 'licence_sync' );
4777 6087
@@ -4904,9 +6214,9 @@
4904 6214 $token = $this->wdkit_login_user_token( $email );
4905 6215 $args = array( 'token' => $token );
4906 6216
4907 6217 if ( 'session' !== $logout_type ) {
4908 - delete_transient( 'wdkit_auth_' . $email );
6218 + delete_transient( 'wdkit_auth_' . wdesignkit_cloud_session_key( $email ) );
4909 6219 // Clear stored license data on logout so banner shows again
4910 6220 delete_option( 'wdkit_licence_data' );
4911 6221 $response = WDesignKit_Data_Query::get_data( 'logout', $args );
4912 6222 }
@@ -4926,9 +6236,9 @@
4926 6236 */
4927 6237 protected function wdkit_login_user_token( $email = '' ) {
4928 6238
4929 6239 if ( ! empty( $email ) ) {
4930 - $user_key = strstr( $email, '@', true );
6240 + $user_key = wdesignkit_cloud_session_key( $email );
4931 6241 $get_login = get_transient( 'wdkit_auth_' . $user_key );
4932 6242
4933 6243 if ( ! empty( $get_login ) && ! empty( $get_login['token'] ) ) {
4934 6244 return $get_login['token'];
@@ -4946,9 +6256,9 @@
4946 6256 * @param string $data send all post data.
4947 6257 * @param string $type store text data.
4948 6258 * @param string $condition store text data.
4949 6259 */
4950 - protected function wdkit_sanitizer_bypass( $data, $type, $condition = 'none' ) {
6260 + protected function wdkit_extract_post_field( $data, $type, $condition = 'none' ) {
4951 6261
4952 6262 if ( 'none' === $condition ) {
4953 6263 return $data[ $type ];
4954 6264 } elseif ( 'cr_widget' === $condition ) {
@@ -4953,8 +6263,10 @@
4953 6263 return $data[ $type ];
4954 6264 } elseif ( 'cr_widget' === $condition ) {
4955 6265 return $data[ $type ];
4956 6266 }
6267 +
6268 + return null;
4957 6269 }
4958 6270
4959 6271
4960 6272 /**