PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.6.4
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.6.4
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 +1377 -183 2.3.32.6.4 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] );
@@ -2600,8 +3064,265 @@
2600 3064 *
2601 3065 * @param array $content store media content.
2602 3066 * @param string $editor it is check editor.
2603 3067 */
3068 + /**
3069 + * Resolve a local upload URL back to its attachment ID.
3070 + *
3071 + * Handles the "-scaled" copy WordPress makes for large originals and any
3072 + * "-1920x1280" size suffix, both of which attachment_url_to_postid() misses because
3073 + * they are not the value stored in _wp_attached_file.
3074 + *
3075 + * @since 2.6.2
3076 + *
3077 + * @param string $url Local upload URL.
3078 + * @return int Attachment ID, or 0.
3079 + */
3080 + private static function wdkit_attachment_id_from_url( $url ) {
3081 + static $cache = array();
3082 +
3083 + if ( isset( $cache[ $url ] ) ) {
3084 + return $cache[ $url ];
3085 + }
3086 +
3087 + $id = (int) attachment_url_to_postid( $url );
3088 +
3089 + if ( ! $id ) {
3090 + // Try the original file behind a -scaled or -WxH derivative.
3091 + $stripped = preg_replace( '/-scaled(\.[a-z0-9]+)$/i', '$1', $url );
3092 + $stripped = preg_replace( '/-\d+x\d+(\.[a-z0-9]+)$/i', '$1', (string) $stripped );
3093 +
3094 + if ( $stripped && $stripped !== $url ) {
3095 + $id = (int) attachment_url_to_postid( $stripped );
3096 + }
3097 + }
3098 +
3099 + // Only remember hits. Page imports run concurrently, so an attachment created by a
3100 + // sibling request may not exist yet when this is first asked — caching that miss
3101 + // would keep every later control in this request pointing at nothing.
3102 + if ( $id ) {
3103 + $cache[ $url ] = $id;
3104 + }
3105 +
3106 + return $id;
3107 + }
3108 +
3109 + /**
3110 + * Is this media reference still pointing off-site?
3111 + *
3112 + * Template content arrives holding the URLs of wherever the media lived before. Those
3113 + * carry that site's attachment IDs, which have no meaning here - and can collide with
3114 + * unrelated local posts.
3115 + *
3116 + * @since 2.6.2
3117 + *
3118 + * @param string $url URL from a media control.
3119 + * @return bool True when the URL points at another site's uploads.
3120 + */
3121 + private static function wdkit_is_foreign_media_url( $url ) {
3122 +
3123 + if ( ! class_exists( 'Wdkit_Image_Guard' ) ) {
3124 + require_once WDKIT_INCLUDES . 'admin/class-wdkit-image-guard.php';
3125 + }
3126 +
3127 + $uploads = wp_get_upload_dir();
3128 +
3129 + return Wdkit_Image_Guard::is_foreign_media( $url, isset( $uploads['baseurl'] ) ? $uploads['baseurl'] : '' );
3130 + }
3131 +
3132 + /**
3133 + * Find - or make - the local attachment behind a source-site media URL.
3134 + *
3135 + * Elementor stamps every image it imports with `_elementor_source_image_hash`
3136 + * (sha1 of the URL it came from), and its importer consults that before doing any
3137 + * network work. Delegating here means a URL already imported at create time resolves
3138 + * from the database, and one that never made it is fetched exactly once.
3139 + *
3140 + * Only ever called for foreign URLs. Handing it a local URL would re-download the
3141 + * file and leave a duplicate, because the stored hash is of the *remote* URL and so
3142 + * would never match.
3143 + *
3144 + * @since 2.6.2
3145 + *
3146 + * @param string $url Source-site media URL.
3147 + * @param int $source_id The source site's attachment ID, used as Elementor's cache key.
3148 + * @return array Local `id` and `url`, or an empty array when it cannot be resolved.
3149 + */
3150 + private static function wdkit_localise_media_url( $url, $source_id = 0 ) {
3151 + static $cache = array();
3152 +
3153 + if ( isset( $cache[ $url ] ) ) {
3154 + return $cache[ $url ];
3155 + }
3156 +
3157 + if ( ! did_action( 'elementor/loaded' ) || ! class_exists( '\\Elementor\\Plugin' ) ) {
3158 + return array();
3159 + }
3160 +
3161 + $images = \Elementor\Plugin::$instance->templates_manager->get_import_images_instance();
3162 +
3163 + if ( ! $images ) {
3164 + return array();
3165 + }
3166 +
3167 + // A download may happen, so keep the oversized-image guard in force.
3168 + self::wdkit_guard_oversized_images();
3169 +
3170 + $imported = $images->import(
3171 + array(
3172 + // Elementor only checks its hash table when an id is present.
3173 + 'id' => $source_id ? $source_id : 1,
3174 + 'url' => $url,
3175 + )
3176 + );
3177 +
3178 + $local = ( ! empty( $imported['id'] ) && ! empty( $imported['url'] ) )
3179 + ? array(
3180 + 'id' => (int) $imported['id'],
3181 + 'url' => $imported['url'],
3182 + )
3183 + : array();
3184 +
3185 + // Remember hits only: a sibling request importing concurrently may simply not have
3186 + // finished yet, and caching that miss would strand every later control on this page.
3187 + if ( $local ) {
3188 + $cache[ $url ] = $local;
3189 + }
3190 +
3191 + return $local;
3192 + }
3193 +
3194 + /**
3195 + * Repair dangling attachment IDs across every page of a finished import.
3196 + *
3197 + * The create-time repair in wdkit_media_import() can only see attachments that already
3198 + * exist. Pages import concurrently and share images — an icon first imported by one
3199 + * page is referenced by several others — so a page that runs early legitimately cannot
3200 + * resolve an image a sibling request has not created yet.
3201 + *
3202 + * This runs at the finalize step, once every page and attachment exists, and fixes
3203 + * whatever the per-page pass had to leave behind.
3204 + *
3205 + * @since 2.6.2
3206 + *
3207 + * @param array $page_ids Imported post IDs.
3208 + * @return int Number of pages actually rewritten.
3209 + */
3210 + private function wdkit_sweep_attachment_ids( $page_ids ) {
3211 +
3212 + if ( empty( $page_ids ) || ! did_action( 'elementor/loaded' ) ) {
3213 + return 0;
3214 + }
3215 +
3216 + $fixed = 0;
3217 + $ids = array_unique( array_map( 'intval', $page_ids ) );
3218 +
3219 + // Primes the meta cache for the whole batch in one query, so the
3220 + // get_post_meta() call below hits the cache instead of issuing one query
3221 + // per imported page.
3222 + update_meta_cache( 'post', $ids );
3223 +
3224 + foreach ( $ids as $post_id ) {
3225 +
3226 + if ( ! $post_id ) {
3227 + continue;
3228 + }
3229 +
3230 + $raw = get_post_meta( $post_id, '_elementor_data', true );
3231 +
3232 + if ( empty( $raw ) ) {
3233 + continue;
3234 + }
3235 +
3236 + $data = is_array( $raw ) ? $raw : json_decode( $raw, true );
3237 +
3238 + if ( ! is_array( $data ) ) {
3239 + continue;
3240 + }
3241 +
3242 + $repaired = self::wdkit_repair_attachment_ids( $data );
3243 +
3244 + if ( wp_json_encode( $repaired ) === wp_json_encode( $data ) ) {
3245 + continue;
3246 + }
3247 +
3248 + // Save through the document API so Elementor regenerates the page CSS — the
3249 + // background-image rules are only emitted once the IDs resolve.
3250 + $document = \Elementor\Plugin::$instance->documents->get( $post_id );
3251 +
3252 + // Count only a save that actually happened. Document::save() returns false
3253 + // without saving when the current user cannot edit the post, and reporting
3254 + // those as repaired hides the fact that nothing changed.
3255 + if ( $document && $document->save( array( 'elements' => $repaired ) ) ) {
3256 + ++$fixed;
3257 + }
3258 + }
3259 +
3260 + if ( $fixed ) {
3261 + \Elementor\Plugin::$instance->files_manager->clear_cache();
3262 + }
3263 +
3264 + return $fixed;
3265 + }
3266 +
3267 + /**
3268 + * Repair media controls whose attachment ID does not resolve.
3269 + *
3270 + * Elementor media controls store `{ url, id }`. Controls flagged `has_sizes` — the
3271 + * container/section **background image** among them — do not render from `url` at all:
3272 + * CSS generation resolves the image through the attachment ID, so a dangling ID
3273 + * produces no `background-image` rule and the section renders with no image even
3274 + * though its URL is perfectly correct.
3275 + *
3276 + * IDs arrive dangling whenever Elementor's own importer does not rewrite a control —
3277 + * it carries the source site's ID, which means nothing locally. Now that the URL is
3278 + * already a local upload before import, the ID can simply be looked up from it.
3279 + *
3280 + * @since 2.6.2
3281 + *
3282 + * @param mixed $node Elementor data, walked recursively.
3283 + * @return mixed Data with resolvable attachment IDs.
3284 + */
3285 + private static function wdkit_repair_attachment_ids( $node ) {
3286 +
3287 + if ( ! is_array( $node ) ) {
3288 + return $node;
3289 + }
3290 +
3291 + // A media control value: has a url, and an id slot to correct.
3292 + if ( isset( $node['url'] ) && is_string( $node['url'] ) && array_key_exists( 'id', $node ) ) {
3293 +
3294 + $current = (int) $node['id'];
3295 + $is_live = $current && 'attachment' === get_post_type( $current );
3296 +
3297 + if ( self::wdkit_is_foreign_media_url( $node['url'] ) ) {
3298 + // Still pointing at the source site. Ask Elementor for the local copy: its
3299 + // _elementor_source_image_hash lookup returns the attachment the create-time
3300 + // import already made, so this normally costs a single query and no download.
3301 + $local = self::wdkit_localise_media_url( $node['url'], $current );
3302 +
3303 + if ( ! empty( $local['id'] ) && ! empty( $local['url'] ) ) {
3304 + $node['id'] = $local['id'];
3305 + $node['url'] = $local['url'];
3306 + }
3307 + } elseif ( ! $is_live && false !== strpos( $node['url'], '/wp-content/uploads/' ) ) {
3308 + $resolved = self::wdkit_attachment_id_from_url( $node['url'] );
3309 +
3310 + if ( $resolved ) {
3311 + $node['id'] = $resolved;
3312 + }
3313 + }
3314 + }
3315 +
3316 + foreach ( $node as $key => $value ) {
3317 + if ( is_array( $value ) ) {
3318 + $node[ $key ] = self::wdkit_repair_attachment_ids( $value );
3319 + }
3320 + }
3321 +
3322 + return $node;
3323 + }
3324 +
2604 3325 public function wdkit_media_import( $content = array(), $editor = '' ) {
2605 3326
2606 3327 if ( empty( $content ) && empty( $editor ) ) {
2607 3328 $args = $this->wdkit_parse_args( $_POST );
@@ -2621,8 +3342,9 @@
2621 3342 if ( ! class_exists( 'Wdkit_Import_Images' ) ) {
2622 3343 require_once WDKIT_INCLUDES . 'admin/class-wdkit-import-images.php';
2623 3344 }
2624 3345
3346 +
2625 3347 if ( ! empty( $args['editor'] ) && 'gutenberg' === $args['editor'] && ! empty( $content ) ) {
2626 3348 $media_import = array( $content );
2627 3349 $media_import = self::blocks_import_media_copy_content( $media_import );
2628 3350 $content = $media_import[0];
@@ -2630,8 +3352,13 @@
2630 3352 $media_import = array( $content );
2631 3353 $media_import = self::widgets_elements_id_change( $media_import );
2632 3354 $media_import = self::widgets_import_media_copy_content( $media_import );
2633 3355 $content = $media_import[0];
3356 +
3357 + // Last: point any control Elementor left holding a foreign attachment ID at the
3358 + // local attachment its URL already refers to. Without this, has_sizes controls
3359 + // such as container background images resolve to nothing and render empty.
3360 + $content = self::wdkit_repair_attachment_ids( $content );
2634 3361 }
2635 3362
2636 3363 return $content;
2637 3364 }
@@ -2702,9 +3429,13 @@
2702 3429 $control_type = \Elementor\Plugin::instance()->controls_manager->get_control( $get_control['type'] );
2703 3430 $control_name = $get_control['name'];
2704 3431
2705 3432 if ( ! $control_type ) {
2706 - return $get_element_instance;
3433 + // Skip just this control. Returning here would abandon every control after
3434 + // it, so a single unregistered type - routine when a kit uses an addon that
3435 + // is not fully active yet - would silently leave the rest of the element's
3436 + // media pointing at the source site.
3437 + continue;
2707 3438 }
2708 3439
2709 3440 if ( method_exists( $control_type, $tp_mi_on_fun ) ) {
2710 3441 $get_element_instance['settings'][ $control_name ] = $control_type->{$tp_mi_on_fun}( $element->get_settings( $control_name ), $get_control );
@@ -2779,73 +3510,298 @@
2779 3510 public static function blocks_data_instance( array $block_data, array $args = array(), $block_args = null ) {
2780 3511
2781 3512 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 3513 $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 ) ) {
3514 + $blocks_attr = self::wdkit_import_block_media( $blocks_attr );
3515 + if ( isset( $block_data['attributes'] ) ) {
3516 + $block_data['attributes'] = $blocks_attr;
3517 + } elseif ( isset( $block_data['attrs'] ) ) {
3518 + $block_data['attrs'] = $blocks_attr;
3519 + }
2794 3520
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 );
3521 + $block_data = self::wdkit_relink_block_markup( $block_data );
3522 + }
2805 3523
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 );
3524 + return $block_data;
3525 + }
2818 3526
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 - }
3527 + /**
3528 + * Run block markup through the media import, the way the create path does.
3529 + *
3530 + * Used wherever block content is written from the browser: media import, then the Nexter
3531 + * block processor so each block's rendered copy matches its attributes, then serialise.
3532 + *
3533 + * @since 2.6.2
3534 + *
3535 + * @param string $content Block markup.
3536 + * @return string Block markup with local media.
3537 + */
3538 + private function wdkit_relink_gutenberg_content( $content ) {
3539 +
3540 + if ( ! is_string( $content ) || false === strpos( $content, '<!-- wp:' ) ) {
3541 + return $content;
3542 + }
3543 +
3544 + // wdkit_media_import() loads this itself, but it is referenced before that below.
3545 + if ( ! class_exists( 'Wdkit_Import_Images' ) ) {
3546 + require_once WDKIT_INCLUDES . 'admin/class-wdkit-import-images.php';
3547 + }
3548 +
3549 + // Thumbnail generation decodes each image, so keep the oversized-image guard in force.
3550 + self::wdkit_guard_oversized_images();
3551 +
3552 +
3553 + // Block attributes are JSON inside the block delimiters, so they only survive a parse
3554 + // when the string carries exactly one level of escaping. Arrive with an extra level and
3555 + // parse_blocks() reads no attributes at all - serialising that back out writes every
3556 + // block bare, throwing away titles, body text, icons and styling.
3557 + $parsable = self::wdkit_parsable_block_content( $content );
3558 +
3559 + if ( null === $parsable ) {
3560 +
3561 + return $content;
3562 + }
3563 +
3564 + $blocks = parse_blocks( $parsable );
3565 + $blocks = $this->wdkit_media_import( $blocks, 'gutenberg' );
3566 +
3567 + if ( empty( $blocks ) || ! is_array( $blocks ) ) {
3568 + return $content;
3569 + }
3570 +
3571 + if ( class_exists( 'WDKIT_Nexter_Block_Processor' ) ) {
3572 + $processor = new WDKIT_Nexter_Block_Processor();
3573 + $blocks = $processor->run( $blocks );
3574 + }
3575 +
3576 + $serialised = serialize_blocks( $blocks );
3577 +
3578 + // Last line of defence. This function exists to repoint media, so a result carrying
3579 + // fewer block attributes than it started with is a broken round trip, not a rewrite.
3580 + // Leaving the media wrong is recoverable; saving gutted content is not.
3581 + $before = self::wdkit_block_attr_count( $parsable );
3582 + $after = self::wdkit_block_attr_count( $serialised );
3583 +
3584 + if ( $after < $before ) {
3585 +
3586 + return $content;
3587 + }
3588 +
3589 + // Never hand back nothing: an empty result would blank the page.
3590 + return ! empty( $serialised ) ? $serialised : $content;
3591 + }
3592 +
3593 + /**
3594 + * Rebuild the block stylesheet for a page whose content we just rewrote.
3595 + *
3596 + * The addon keeps each block's styling in a generated per-page stylesheet, and every rule
3597 + * is keyed to the block id it was written for. That file is produced when the page is
3598 + * saved through the editor - not by wp_update_post() from an AJAX handler - so rewriting
3599 + * content here leaves the page pointing at a stylesheet built for the previous markup.
3600 + * Blocks whose ids are not in that file get no rules at all and render unstyled.
3601 + *
3602 + * @since 2.6.2
3603 + *
3604 + * @param int $post_id Page whose content changed.
3605 + * @return bool True when a rebuild was triggered.
3606 + */
3607 + private static function wdkit_rebuild_block_css( $post_id ) {
3608 +
3609 + if ( ! $post_id ) {
3610 + return false;
3611 + }
3612 +
3613 + foreach ( get_declared_classes() as $class ) {
3614 + if ( ! method_exists( $class, 'make_block_css_by_post_id' ) ) {
3615 + continue;
3616 + }
3617 +
3618 + try {
3619 + if ( method_exists( $class, 'instance' ) ) {
3620 + $instance = $class::instance();
3621 + } elseif ( method_exists( $class, 'get_instance' ) ) {
3622 + $instance = $class::get_instance();
3623 + } else {
3624 + $instance = new $class();
2835 3625 }
3626 +
3627 + $instance->make_block_css_by_post_id( $post_id );
3628 +
3629 +
3630 + return true;
3631 + } catch ( \Throwable $e ) {
3632 + // Styling is best-effort: a failure here must not fail the import.
3633 +
3634 + return false;
2836 3635 }
2837 - if ( isset( $block_data['attributes'] ) ) {
2838 - $block_data['attributes'] = $blocks_attr;
2839 - } elseif ( isset( $block_data['attrs'] ) ) {
2840 - $block_data['attrs'] = $blocks_attr;
3636 + }
3637 +
3638 + return false;
3639 + }
3640 +
3641 + /**
3642 + * How many block attributes does this markup actually yield when parsed?
3643 + *
3644 + * Used as a before/after measure: block attributes are the part of block markup a round
3645 + * trip can silently drop, so counting them is how we tell a rewrite from a mangling.
3646 + *
3647 + * @since 2.6.2
3648 + *
3649 + * @param string $content Block markup.
3650 + * @return int Total attributes across every block.
3651 + */
3652 + private static function wdkit_block_attr_count( $content ) {
3653 + $total = 0;
3654 +
3655 + $walk = function ( $blocks ) use ( &$walk, &$total ) {
3656 + foreach ( $blocks as $block ) {
3657 + if ( ! empty( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
3658 + $total += count( $block['attrs'] );
3659 + }
3660 +
3661 + if ( ! empty( $block['innerBlocks'] ) ) {
3662 + $walk( $block['innerBlocks'] );
3663 + }
2841 3664 }
3665 + };
3666 +
3667 + $walk( parse_blocks( (string) $content ) );
3668 +
3669 + return $total;
3670 + }
3671 +
3672 + /**
3673 + * Return this content in a form whose block attributes actually parse.
3674 + *
3675 + * Content written straight to post_content never had to parse, so an extra level of
3676 + * escaping on the way in did no harm. Parsing it - which repointing media requires - makes
3677 + * that escaping fatal: `{\"Title\":\"…\"}` is not JSON, so every attribute is discarded.
3678 + *
3679 + * Rather than assume a slash depth, this measures: if stripping one level yields more
3680 + * attributes, the content was over-escaped and the stripped form is the real one.
3681 + *
3682 + * @since 2.6.2
3683 + *
3684 + * @param string $content Block markup as received.
3685 + * @return string|null Markup safe to parse, or null when no form of it parses.
3686 + */
3687 + private static function wdkit_parsable_block_content( $content ) {
3688 +
3689 + $as_is = self::wdkit_block_attr_count( $content );
3690 +
3691 + // Nothing claims to carry attributes, so there is nothing to lose either.
3692 + if ( false === strpos( $content, '{' ) ) {
3693 + return $content;
2842 3694 }
2843 3695
3696 + $stripped = wp_unslash( $content );
3697 + $stripped_attrs = self::wdkit_block_attr_count( $stripped );
3698 +
3699 + if ( $stripped_attrs > $as_is ) {
3700 + return $stripped;
3701 + }
3702 +
3703 + if ( $as_is > 0 ) {
3704 + return $content;
3705 + }
3706 +
3707 + // Neither form parses into attributes even though the markup contains JSON: better to
3708 + // leave the content exactly as it arrived than to rewrite it into something bare.
3709 + return null;
3710 + }
3711 +
3712 + /**
3713 + * Point a block's saved markup at the media that was just localised.
3714 + *
3715 + * A block stores a rendered copy of itself in `innerHTML` / `innerContent`, and for many
3716 + * blocks that copy is what the front end actually outputs. Importing the attributes alone
3717 + * therefore fixes the editor while leaving the page still loading from the site the
3718 + * template came from - and those hosts answer 403, so the image renders broken.
3719 + *
3720 + * @since 2.6.2
3721 + *
3722 + * @param array $block_data One parsed block.
3723 + * @return array The block with its markup repointed.
3724 + */
3725 + private static function wdkit_relink_block_markup( $block_data ) {
3726 +
3727 + $map = Wdkit_Import_Images::get_url_map();
3728 +
3729 + if ( empty( $map ) ) {
3730 + return $block_data;
3731 + }
3732 +
3733 + $from = array_keys( $map );
3734 + $to = array_values( $map );
3735 +
3736 + if ( ! empty( $block_data['innerHTML'] ) && is_string( $block_data['innerHTML'] ) ) {
3737 + $block_data['innerHTML'] = str_replace( $from, $to, $block_data['innerHTML'] );
3738 + }
3739 +
3740 + if ( ! empty( $block_data['innerContent'] ) && is_array( $block_data['innerContent'] ) ) {
3741 + foreach ( $block_data['innerContent'] as $index => $chunk ) {
3742 + if ( is_string( $chunk ) ) {
3743 + $block_data['innerContent'][ $index ] = str_replace( $from, $to, $chunk );
3744 + }
3745 + }
3746 + }
3747 +
2844 3748 return $block_data;
2845 3749 }
2846 3750
2847 3751 /**
3752 + * Import every media reference held in a block's attributes.
3753 + *
3754 + * Block attributes nest arbitrarily - a repeater of cards each with an image, responsive
3755 + * variants, nested inner settings - so this recurses rather than reaching a fixed number
3756 + * of levels down. The previous version was unrolled exactly four levels deep and also
3757 + * skipped any subtree carrying an `md` key, which meant anything below that simply kept
3758 + * the source site's URL and attachment ID and rendered as an empty placeholder.
3759 + *
3760 + * A node counts as media when it has a non-empty string `url` and either an id slot or a
3761 + * URL that names an image file. That pairing is what distinguishes a media control from
3762 + * a link, which also carries a `url`.
3763 + *
3764 + * @since 2.6.2
3765 + *
3766 + * @param mixed $node Block attributes, walked recursively.
3767 + * @return mixed Attributes with local media.
3768 + */
3769 + private static function wdkit_import_block_media( $node ) {
3770 +
3771 + if ( ! is_array( $node ) ) {
3772 + return $node;
3773 + }
3774 +
3775 + $url = isset( $node['url'] ) && is_string( $node['url'] ) ? $node['url'] : '';
3776 +
3777 + if ( '' !== $url
3778 + && ( array_key_exists( 'id', $node ) || array_key_exists( 'Id', $node )
3779 + || preg_match( '/\.(?:jpe?g|png|gif|svg|webp|avif|bmp)$/i', (string) wp_parse_url( $url, PHP_URL_PATH ) ) )
3780 + ) {
3781 + $imported = Wdkit_Import_Images::wdkit_Import_media( $node );
3782 +
3783 + // Only accept a real result. The importer returns the node untouched when it
3784 + // cannot localise the file, and anything falsy here would wipe out the URL and
3785 + // leave the block with no image at all.
3786 + if ( ! empty( $imported['url'] ) ) {
3787 + $node = array_merge( $node, $imported );
3788 + }
3789 + }
3790 +
3791 + // Keep walking even after importing this node. A media value carries its own `sizes`
3792 + // map of per-size URLs, and returning here left every one of those pointing at the
3793 + // site the template came from - which is what the widgets actually render from.
3794 + foreach ( $node as $key => $value ) {
3795 + if ( is_array( $value ) ) {
3796 + $node[ $key ] = self::wdkit_import_block_media( $value );
3797 + }
3798 + }
3799 +
3800 + return $node;
3801 + }
3802 +
3803 + /**
2848 3804 * Kit Template Import Pages/Sections
2849 3805 *
2850 3806 * @since 1.0.0
2851 3807 * */
@@ -3032,9 +3988,17 @@
3032 3988 );
3033 3989
3034 3990 $response = WDesignKit_Data_Query::get_data( $api_type, $temp_args );
3035 3991
3036 - if ( 'error' === $response['content'] ) {
3992 + if ( is_wp_error( $response ) ) {
3993 + wp_send_json( array(
3994 + 'success' => false,
3995 + 'message' => $response->get_error_message(),
3996 + ) );
3997 + wp_die();
3998 + }
3999 +
4000 + if ( isset( $response['content'] ) && 'error' === $response['content'] ) {
3037 4001 wp_send_json( $response );
3038 4002 wp_die();
3039 4003 }
3040 4004
@@ -3102,8 +4066,14 @@
3102 4066 }
3103 4067
3104 4068 $document = \Elementor\Plugin::$instance->documents->get($template_id);
3105 4069
4070 + // This saves content posted straight from the browser, which carries local image
4071 + // URLs but still the source template's attachment IDs. Without repairing them the
4072 + // save undoes what wdkit_media_import() fixed on create, and has_sizes controls —
4073 + // container background images especially — resolve to nothing and render empty.
4074 + $content = self::wdkit_repair_attachment_ids( $content );
4075 +
3106 4076 $document->save([
3107 4077 'elements' => $content
3108 4078 ]);
3109 4079 }
@@ -3108,8 +4078,94 @@
3108 4078 ]);
3109 4079 }
3110 4080
3111 4081 /**
4082 + * Update the content of an already-created page.
4083 + *
4084 + * Used by the async ("Site Ready first") import path: pages are created up front with
4085 + * their un-rewritten template content, then this writes the AI-rewritten content into
4086 + * each page in the background. Elementor saves via the document API (same as
4087 + * wkit_update_elementor_template); Gutenberg writes post_content directly.
4088 + *
4089 + * @since 2.6.2
4090 + */
4091 + protected function wdkit_update_page_content() {
4092 + $post_id = isset( $_POST['post_id'] ) ? intval( $_POST['post_id'] ) : 0;
4093 + $builder = isset( $_POST['builder'] ) ? sanitize_text_field( wp_unslash( $_POST['builder'] ) ) : '';
4094 +
4095 + if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) {
4096 + return array(
4097 + 'success' => false,
4098 + 'message' => esc_html__( 'Invalid page or insufficient permission', 'wdesignkit' ),
4099 + );
4100 + }
4101 +
4102 + if ( 'gutenberg' === $builder ) {
4103 + // Do NOT run kses here: Gutenberg block delimiters are HTML comments
4104 + // (<!-- wp:... -->) which kses strips. Mirror the create path, which stores
4105 + // the block markup slashed and unfiltered (endpoint is manage_options-gated
4106 + // and the content is plugin-generated).
4107 + $content = isset( $_POST['content'] ) ? wp_unslash( $_POST['content'] ) : '';
4108 +
4109 + // This content comes straight from the browser and still carries the template
4110 + // site's media URLs and attachment IDs, so it has to go through the same pipeline
4111 + // the create path uses. Without this the save simply undid the import: the files
4112 + // were fetched, then overwritten by a copy still pointing at the source site.
4113 + //
4114 + // Re-running is cheap. Every URL already handled resolves from the source-hash
4115 + // lookup, and media that is already local resolves straight from its URL, so no
4116 + // image is fetched or stored twice.
4117 + $content = $this->wdkit_relink_gutenberg_content( $content );
4118 +
4119 + $result = wp_update_post(
4120 + array(
4121 + 'ID' => $post_id,
4122 + 'post_content' => wp_slash( $content ),
4123 + ),
4124 + true
4125 + );
4126 +
4127 + if ( is_wp_error( $result ) ) {
4128 + return array(
4129 + 'success' => false,
4130 + 'message' => $result->get_error_message(),
4131 + );
4132 + }
4133 +
4134 + self::wdkit_rebuild_block_css( $post_id );
4135 + } else {
4136 + $elements = isset( $_POST['content'] ) ? json_decode( wp_unslash( $_POST['content'] ), true ) : array();
4137 +
4138 + if ( ! class_exists( '\\Elementor\\Plugin' ) ) {
4139 + return array(
4140 + 'success' => false,
4141 + 'message' => esc_html__( 'Elementor not available', 'wdesignkit' ),
4142 + );
4143 + }
4144 +
4145 + $document = \Elementor\Plugin::$instance->documents->get( $post_id );
4146 + if ( ! $document ) {
4147 + return array(
4148 + 'success' => false,
4149 + 'message' => esc_html__( 'Elementor document not found', 'wdesignkit' ),
4150 + );
4151 + }
4152 +
4153 + // Same as wkit_update_elementor_template(): browser-posted content keeps the
4154 + // source template's attachment IDs, so repair them or this save undoes the
4155 + // create-time fix and background images stop rendering.
4156 + $elements = self::wdkit_repair_attachment_ids( $elements );
4157 +
4158 + $document->save( array( 'elements' => $elements ) );
4159 + }
4160 +
4161 + return array(
4162 + 'success' => true,
4163 + 'message' => esc_html__( 'Page content updated', 'wdesignkit' ),
4164 + );
4165 + }
4166 +
4167 + /**
3112 4168 * Import single template and section from plugin only
3113 4169 *
3114 4170 * @param array $args store data.
3115 4171 * @param array $template_id store data.
@@ -3117,8 +4173,21 @@
3117 4173 * @param array $temp_data store data.
3118 4174 * */
3119 4175 protected function import_page_section_content() {
3120 4176
4177 + // Elementor sideloads every image referenced by the page from inside this request.
4178 + // A single oversized source image decodes to more than the whole memory limit, so
4179 + // guard before any of that starts.
4180 + $this->wdkit_guard_oversized_images();
4181 +
4182 + // Sideloading images for image-heavy pages (wdkit_media_import → Imagick
4183 + // thumbnail generation per image) can exceed the default 30s execution
4184 + // limit and fatal the request mid-import. Give this single page import
4185 + // more headroom; harmless no-op where set_time_limit() is disabled.
4186 + if ( function_exists( 'set_time_limit' ) ) {
4187 + @set_time_limit( 120 );
4188 + }
4189 +
3121 4190 if ( isset( $_POST['args'] ) ) {
3122 4191 $args = ! empty( $_POST['args'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['args'] ) ), true ) : array();
3123 4192 }
3124 4193
@@ -3141,11 +4210,9 @@
3141 4210 if ( isset( $_POST['template_id'] ) ) {
3142 4211 $template_id = ! empty( $_POST['template_id'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['template_id'] ), true ) ) : '';
3143 4212 }
3144 4213
3145 - if ( isset( $_POST['temp_type'] ) ) {
3146 - $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'normal';
3147 - }
4214 + $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'normal';
3148 4215
3149 4216 if ( isset( $_POST['data'] ) ) {
3150 4217 $data = ! empty( $_POST['data'] ) ? json_decode( wp_unslash( $_POST['data'] ) ) : '';
3151 4218 }
@@ -3166,8 +4233,21 @@
3166 4233 }
3167 4234
3168 4235 if ( ! empty( $data ) && ! empty( $template_id ) && ! empty( $post_type ) && current_user_can( 'manage_options' ) ) {
3169 4236 $post_content = $data;
4237 + // Restore The Plus Addons' globals before the page is built, so the widgets'
4238 + // tp_global_preset references resolve as soon as it renders. Done here rather
4239 + // than in the save-template UI's confirmation dialog so that every import path
4240 + // - the library, the abilities, the theme builder - gets it.
4241 + if ( isset( $post_content->tp_globals ) && ! empty( $post_content->tp_globals ) ) {
4242 + $this->wdkit_merge_tp_globals(
4243 + json_decode( wp_json_encode( $post_content->tp_globals ), true ),
4244 + isset( $post_content->tp_global_refs )
4245 + ? json_decode( wp_json_encode( $post_content->tp_global_refs ), true )
4246 + : array()
4247 + );
4248 + }
4249 +
3170 4250 $post_title = isset( $post_content->title ) ? sanitize_text_field( $post_content->title ) : '';
3171 4251 $post_slug = isset( $post_content->slug ) ? sanitize_text_field( $post_content->slug ) : '';
3172 4252 $file_type = isset( $post_content->file_type ) ? sanitize_text_field( $post_content->file_type ) : '';
3173 4253 $content = isset( $post_content->content ) ? wp_slash( $post_content->content ) : '';
@@ -3177,9 +4257,9 @@
3177 4257 if ( empty( $content ) ) {
3178 4258 wp_send_json(
3179 4259 array(
3180 4260 'template_id' => $template_id,
3181 - 'message' => 'Content is Empty.',
4261 + 'message' => __( 'Content is Empty.', 'wdesignkit' ),
3182 4262 )
3183 4263 );
3184 4264 wp_die();
3185 4265 } elseif ( ! empty( $content ) && ! empty( $file_type ) && 'wp_block' === $file_type ) {
@@ -3185,27 +4265,17 @@
3185 4265 } elseif ( ! empty( $content ) && ! empty( $file_type ) && 'wp_block' === $file_type ) {
3186 4266
3187 4267 $editor = ( 'wdkit' === $args['editor'] ) ? 'gutenberg' : $args['editor'];
3188 4268 $blocks = parse_blocks( stripslashes( $content ) );
3189 -
4269 +
3190 4270 $blocks = $this->wdkit_media_import( $blocks, $editor );
3191 4271
3192 4272 $processor = new WDKIT_Nexter_Block_Processor();
3193 4273 $blocks = $processor->run( $blocks );
3194 4274 $content = serialize_blocks( $blocks );
3195 -
4275 +
3196 4276 $content = $this->replace_unicode_glitch( serialize_blocks( $blocks ) );
3197 4277
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 4278 $inserted_post = wp_insert_post(
3209 4279 array(
3210 4280 'post_status' => 'publish',
3211 4281 'post_type' => $post_type,
@@ -3224,9 +4294,9 @@
3224 4294 );
3225 4295 wp_die();
3226 4296 }
3227 4297
3228 - if ( ! empty( $thumb_image ) ) {
4298 + if ( ! empty( $thumb_image ) && wdesignkit_validate_external_url( $thumb_image ) ) {
3229 4299 // $featured_image_url = esc_url_raw( $thumb_image );
3230 4300 $tmp = download_url( $thumb_image );
3231 4301 if ( is_wp_error( $tmp ) ) {
3232 4302 error_log( 'Image download failed: ' . esc_html( $tmp->get_error_message() ) );
@@ -3262,9 +4332,9 @@
3262 4332 $custom_meta = isset( $post_content->custom_meta ) ? json_decode( wp_json_encode( $post_content->custom_meta ), true ) : '';
3263 4333 if ( ! empty( $custom_meta ) ) {
3264 4334 foreach ( $custom_meta as $meta_key => $meta_val ) {
3265 4335 if ( isset( $meta_val[0] ) && ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
3266 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
4336 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
3267 4337 }
3268 4338
3269 4339 if ( '' === get_post_meta( $inserted_post, $meta_key, true ) && isset( $meta_val[0] ) ) {
3270 4340 add_post_meta( $inserted_post, $meta_key, $meta_val[0] );
@@ -3300,9 +4370,9 @@
3300 4370 wp_send_json(
3301 4371 array(
3302 4372 $temp_id => $temp_detail,
3303 4373 'description' => 'Yay! Your Section has been Successfully Imported.',
3304 - 'message' => 'Successfully Imported.',
4374 + 'message' => __( 'Successfully Imported.', 'wdesignkit' ),
3305 4375 'inserted_id' => $inserted_post,
3306 4376 'success' => true,
3307 4377 )
3308 4378 );
@@ -3313,9 +4383,9 @@
3313 4383 if ( empty( $content ) ) {
3314 4384 wp_send_json(
3315 4385 array(
3316 4386 'template_id' => $template_id,
3317 - 'message' => 'Content is Empty.',
4387 + 'message' => __( 'Content is Empty.', 'wdesignkit' ),
3318 4388 )
3319 4389 );
3320 4390 wp_die();
3321 4391 } elseif ( ! empty( $content ) && ! empty( $file_type ) && 'elementor' === $file_type ) {
@@ -3353,9 +4423,9 @@
3353 4423 }
3354 4424
3355 4425 $inserted_id = $new_document->get_main_id();
3356 4426
3357 - if ( ! empty( $thumb_image ) ) {
4427 + if ( ! empty( $thumb_image ) && wdesignkit_validate_external_url( $thumb_image ) ) {
3358 4428 // $featured_image_url = esc_url_raw( $thumb_image );
3359 4429 $tmp = download_url( $thumb_image );
3360 4430 if ( is_wp_error( $tmp ) ) {
3361 4431 error_log( 'Image download failed: ' . esc_html( $tmp->get_error_message() ) );
@@ -3409,9 +4479,9 @@
3409 4479 $custom_meta = isset( $post_content->custom_meta ) ? json_decode( wp_json_encode( $post_content->custom_meta ), true ) : '';
3410 4480 if ( ! empty( $custom_meta ) ) {
3411 4481 foreach ( $custom_meta as $meta_key => $meta_val ) {
3412 4482 if ( ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
3413 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
4483 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
3414 4484 }
3415 4485 if ( '' === get_post_meta( $inserted_id, $meta_key, true ) ) {
3416 4486 add_post_meta( $inserted_id, $meta_key, $meta_val[0] );
3417 4487 }
@@ -3440,9 +4510,9 @@
3440 4510 array(
3441 4511 $temp_id => $temp_detail,
3442 4512 'content' => $temp_con,
3443 4513 'description' => 'Yay! Your Section has been Successfully Imported.',
3444 - 'message' => 'Successfully Imported.',
4514 + 'message' => __( 'Successfully Imported.', 'wdesignkit' ),
3445 4515 'inserted_id' => $inserted_id,
3446 4516 'success' => true,
3447 4517 )
3448 4518 );
@@ -3572,8 +4642,12 @@
3572 4642 }
3573 4643
3574 4644 $image_url = esc_url_raw( $_POST['image_url'] );
3575 4645
4646 + if ( ! wdesignkit_validate_external_url( $image_url ) ) {
4647 + wp_send_json_error( 'Image could not be downloaded.' );
4648 + }
4649 +
3576 4650 $tmp_file = download_url( $image_url );
3577 4651 if ( is_wp_error( $tmp_file ) ) {
3578 4652 wp_send_json_error( 'Image could not be downloaded.' );
3579 4653 }
@@ -3609,9 +4683,21 @@
3609 4683
3610 4684 $upload_dir = wp_upload_dir();
3611 4685 $result_urls = array();
3612 4686
4687 + $colour_index = 0;
3613 4688 foreach ( $img_colors as $name => $rgb ) {
4689 + // $name is a key from the posted colours payload and went straight into the output
4690 + // filename, so traversal sequences in it steered imagepng() outside the upload
4691 + // directory (CWE-22, ClickUp 86d41ced6). sanitize_file_name() flattens it to one
4692 + // path segment; a key made only of dots/separators sanitizes to empty, so fall back
4693 + // to a positional index rather than writing to a bare "colored--<time>.png".
4694 + ++$colour_index;
4695 + $safe_name = sanitize_file_name( (string) $name );
4696 + if ( '' === $safe_name ) {
4697 + $safe_name = 'colour-' . $colour_index;
4698 + }
4699 +
3614 4700 $new = imagecreatetruecolor( $width, $height );
3615 4701 imagesavealpha( $new, true );
3616 4702 imagealphablending( $new, false );
3617 4703
@@ -3637,9 +4723,9 @@
3637 4723 imagesetpixel( $new, $x, $y, $color );
3638 4724 }
3639 4725 }
3640 4726
3641 - $filename = 'colored-' . $name . '-' . time() . '.png';
4727 + $filename = 'colored-' . $safe_name . '-' . time() . '.png';
3642 4728 $filepath = $upload_dir['path'] . '/' . $filename;
3643 4729
3644 4730 imagepng( $new, $filepath );
3645 4731 imagedestroy( $new );
@@ -3855,8 +4941,13 @@
3855 4941 $page_information = isset( $_POST['page_information'] ) ? sanitize_text_field( wp_unslash( $_POST['page_information'] ) ) : '';
3856 4942 $page_information = json_decode( $page_information, true );
3857 4943
3858 4944 if ( ! empty( $page_information ) && is_array( $page_information ) ) {
4945 +
4946 + // Every page and attachment now exists, so resolve any image ID the per-page
4947 + // pass could not (siblings import concurrently and share icons).
4948 + $this->wdkit_sweep_attachment_ids( wp_list_pluck( $page_information, 'inserted_id' ) );
4949 +
3859 4950 // Step 1: banavo mapping [ old_id => new_id ]
3860 4951 $id_mapping = array();
3861 4952 foreach ( $page_information as $page_info ) {
3862 4953 if ( ! empty( $page_info['old_page_id'] ) ) {
@@ -4066,9 +5157,10 @@
4066 5157 protected function wdkit_activate_key() {
4067 5158 $email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
4068 5159 $response = '';
4069 5160
4070 - if ( empty( $user_email ) ) {
5161 + // Bug C fix: variable was $user_email but only $email is set above — always triggered empty() guard.
5162 + if ( empty( $email ) ) {
4071 5163 $response = array(
4072 5164 'message' => $this->e_msg_login,
4073 5165 'description' => $this->e_desc_login,
4074 5166 'success' => false,
@@ -4286,10 +5378,35 @@
4286 5378 'description' => esc_html__( 'widget JSON file not found.', 'wdesignkit' ),
4287 5379 );
4288 5380 }
4289 5381
4290 - $json_path = WDKIT_BUILDER_PATH . "/{$widget_type}/{$folder_name}/{$file_name}";
5382 + // Read-side twin of the write and delete traversals fixed in 86d41cckh / 86d41ccz2: all
5383 + // three segments arrive from $_POST with only wp_unslash() applied — which strips
5384 + // nothing path-relevant — so "../" in any of them walked out of the builder directory
5385 + // and this handler returned the decoded contents of any .json file the web server user
5386 + // could read (CWE-22, ClickUp 86d41zaun).
5387 + $safe_path = wdesignkit_widget_path_guard( $widget_type, $folder_name, $file_name );
4291 5388
5389 + if ( false === $safe_path || '' === $safe_path['folder'] || '' === $safe_path['file'] ) {
5390 + return array(
5391 + 'success' => false,
5392 + 'message' => esc_html__( 'Widget JSON not found', 'wdesignkit' ),
5393 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
5394 + );
5395 + }
5396 +
5397 + $json_path = $safe_path['base'];
5398 +
5399 + // Re-check the resolved file: the component guard above cannot see a symlink. Returns
5400 + // false for a path that does not exist, which is the same answer we want anyway.
5401 + if ( ! wdesignkit_path_inside_builder_dir( "$json_path.json" ) ) {
5402 + return array(
5403 + 'success' => false,
5404 + 'message' => esc_html__( 'Widget JSON not found', 'wdesignkit' ),
5405 + 'description' => esc_html__( 'widget JSON file not found.', 'wdesignkit' ),
5406 + );
5407 + }
5408 +
4292 5409 $json_data = wp_json_file_decode( "$json_path.json" );
4293 5410 if ( ! empty( $json_data ) ) {
4294 5411 $result = (object) array(
4295 5412 'success' => true,
@@ -4315,9 +5432,9 @@
4315 5432 *
4316 5433 * @since 1.0.0
4317 5434 */
4318 5435 protected function wdkit_download_widget() {
4319 - $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_sanitizer_bypass( $_POST, 'widget_info', 'none' ) : '';
5436 + $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_extract_post_field( $_POST, 'widget_info', 'none' ) : '';
4320 5437 $data = json_decode( stripslashes( $data ) );
4321 5438
4322 5439 $array_data = array(
4323 5440 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
@@ -4322,8 +5439,10 @@
4322 5439 $array_data = array(
4323 5440 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
4324 5441 'type' => isset( $data->type ) ? sanitize_text_field( $data->type ) : '',
4325 5442 'w_unique' => isset( $data->w_uniq ) ? sanitize_text_field( $data->w_uniq ) : '',
5443 + // Bug F fix: u_id (widget owner's user ID) was missing — cloud cannot locate the widget without it.
5444 + 'u_id' => isset( $data->u_id ) ? sanitize_text_field( $data->u_id ) : '',
4326 5445 );
4327 5446
4328 5447 $response = $this->wkit_api_call( $array_data, 'save_widget' );
4329 5448 $success = ! empty( $response['success'] ) ? $response['success'] : false;
@@ -4358,10 +5477,11 @@
4358 5477
4359 5478 $img_url = ! empty( $response['data']['image'] ) ? $response['data']['image'] : '';
4360 5479 $json_data = ! empty( $response['data']['json'] ) ? json_decode( $response['data']['json'], true ) : '';
4361 5480
5481 + // Bug E fix (part 1): $responce was a typo of $response — sent undefined variable (null) to frontend.
4362 5482 if ( empty( $response['success'] ) ) {
4363 - wp_send_json( $responce );
5483 + wp_send_json( $response );
4364 5484 wp_die();
4365 5485 }
4366 5486
4367 5487 if ( empty( $img_url ) && empty( $json_data ) ) {
@@ -4382,14 +5502,34 @@
4382 5502 if ( ! is_array( $json_data ) ) {
4383 5503 $json_data = json_decode( $json_data, true );
4384 5504 }
4385 5505
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'] ) : '';
5506 + // Sanitize as filenames before use in the widget path (CWE-22): sanitize_file_name()
5507 + // on name/id and sanitize_key() + allowlist on the builder strip path separators and
5508 + // dots so a crafted cloud response cannot escape WDKIT_BUILDER_PATH.
5509 + $title = ! empty( $json_data['widget_data']['widgetdata']['name'] ) ? sanitize_file_name( $json_data['widget_data']['widgetdata']['name'] ) : '';
5510 + $builder = ! empty( $json_data['widget_data']['widgetdata']['type'] ) ? sanitize_key( $json_data['widget_data']['widgetdata']['type'] ) : '';
5511 + $w_uniq = ! empty( $json_data['widget_data']['widgetdata']['widget_id'] ) ? sanitize_file_name( $json_data['widget_data']['widgetdata']['widget_id'] ) : '';
4389 5512
4390 - $folder_name = str_replace( ' ', '-', $title ) . '_' . $w_uniq;
4391 - $file_name = str_replace( ' ', '_', $title ) . '_' . $w_uniq;
5513 + $allowed_builders = array( 'elementor', 'gutenberg', 'gutenberg_core', 'bricks' );
5514 + if ( '' === $title || '' === $w_uniq || ! in_array( $builder, $allowed_builders, true ) ) {
5515 + $responce = (object) array(
5516 + 'success' => false,
5517 + 'message' => esc_html__( 'Operation Failed!', 'wdesignkit' ),
5518 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
5519 + );
5520 +
5521 + wp_send_json( $responce );
5522 + wp_die();
5523 + }
5524 +
5525 + // Canonical helpers replace spaces BEFORE sanitize_file_name(). $title above is
5526 + // already sanitized, which collapsed spaces to hyphens and left the underscore pass
5527 + // with nothing to do — a multi-word title wrote "My-Widget_id.json" next to the
5528 + // "My_Widget_id.php" the builder's save path writes. The loader pairs the two by
5529 + // swapping .php for .json, so the widget was silently dropped (ClickUp 86d41cck5).
5530 + $folder_name = wdesignkit_widget_folder_name( $title, $w_uniq );
5531 + $file_name = wdesignkit_widget_file_name( $title, $w_uniq );
4392 5532 $builder_type_path = WDKIT_BUILDER_PATH . "/{$builder}/";
4393 5533
4394 5534 if ( ! is_dir( $builder_type_path ) ) {
4395 5535 wp_mkdir_p( $builder_type_path );
@@ -4399,17 +5539,30 @@
4399 5539 wp_mkdir_p( $builder_type_path . $folder_name );
4400 5540 }
4401 5541
4402 5542 if ( ! empty( $img_url ) ) {
4403 - $img_body = wp_remote_get( $img_url );
4404 - $img_ext = pathinfo( $img_url )['extension'];
5543 + // SSRF guard (CWE-918): validate the resolved host before fetching.
5544 + $img_body = wdesignkit_safe_remote_get( $img_url );
5545 + if ( ! is_wp_error( $img_body ) ) {
5546 + // The remote extension was written verbatim here, so a cloud response naming a
5547 + // ".php" image put executable PHP in the builder directory (CWE-434,
5548 + // ClickUp 86d41cczd). An empty return means the bytes are not an image.
5549 + $img_ext = wdesignkit_safe_image_extension( $img_url, $img_body['body'] );
4405 5550
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";
5551 + if ( '' !== $img_ext ) {
5552 + $wp_filesystem->put_contents( WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name.$img_ext", $img_body['body'] );
5553 + $json_data['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
5554 + }
5555 + }
4408 5556 }
4409 5557
5558 + if ( function_exists( 'wdesignkit_invalidate_widget_registry' ) ) {
5559 + wdesignkit_invalidate_widget_registry( $builder );
5560 + }
5561 +
5562 + // Bug E fix (part 2): success was hardcoded false on the successful download path — always reported failure.
4410 5563 $result = (object) array(
4411 - 'success' => false,
5564 + 'success' => true,
4412 5565 'message' => ! empty( $response['message'] ) ? $response['message'] : esc_html__( 'no message', 'wdesignkit' ),
4413 5566 'description' => '',
4414 5567 'json' => wp_json_encode( $json_data ),
4415 5568 );
@@ -4424,9 +5577,9 @@
4424 5577 *
4425 5578 * @since 1.0.0
4426 5579 */
4427 5580 protected function wdkit_add_widget() {
4428 - $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_sanitizer_bypass( $_POST, 'widget_info', 'none' ) : '';
5581 + $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_extract_post_field( $_POST, 'widget_info', 'none' ) : '';
4429 5582 $data = base64_decode( $data );
4430 5583 $data = json_decode( $data );
4431 5584
4432 5585 $title = isset( $data->title ) ? sanitize_text_field( $data->title ) : '';
@@ -4435,9 +5588,11 @@
4435 5588 $w_image = isset( $data->w_image ) ? esc_url_raw( $data->w_image ) : '';
4436 5589
4437 5590 if ( ! empty( $w_image ) ) {
4438 5591 $w_image = str_replace( '\\', '', $w_image );
4439 - $w_image = wp_remote_get( $w_image )['body'];
5592 + // SSRF guard (CWE-918): validate the resolved host before fetching.
5593 + $fetched = wdesignkit_safe_remote_get( $w_image );
5594 + $w_image = is_wp_error( $fetched ) ? '' : wp_remote_retrieve_body( $fetched );
4440 5595 }
4441 5596
4442 5597 $array_data = array(
4443 5598 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
@@ -4477,22 +5632,51 @@
4477 5632 $img_url = ! empty( $response['data']['imgurl'] ) ? $response['data']['imgurl'] : '';
4478 5633
4479 5634 if ( ! empty( $img_url ) && 'error' !== $res ) {
4480 5635
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";
5636 + // SSRF guard (CWE-918): validate the resolved host before fetching.
5637 + $img_body = wdesignkit_safe_remote_get( $img_url );
5638 + if ( ! is_wp_error( $img_body ) ) {
5639 + // Verified against the payload rather than trusted from the URL (CWE-434,
5640 + // ClickUp 86d41cczd); '' means the bytes are not an image we accept.
5641 + $img_ext = wdesignkit_safe_image_extension( $img_url, $img_body['body'] );
5642 + include_once ABSPATH . 'wp-admin/includes/file.php';
5643 + \WP_Filesystem();
5644 + global $wp_filesystem;
5645 + // Canonical helpers, so the JSON read and the image write here address the same
5646 + // base name every other writer uses (ClickUp 86d41cck5). They also apply
5647 + // sanitize_file_name(), which $title and $w_uniq had not been through.
5648 + $folder_name = wdesignkit_widget_folder_name( $title, $w_uniq );
5649 + $file_name = wdesignkit_widget_file_name( $title, $w_uniq );
4489 5650
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";
5651 + // $builder reaches here with only sanitize_text_field() applied and no
5652 + // allowlist, so it was a live traversal segment in this path (CWE-22,
5653 + // ClickUp 86d41cckh). Unlike the download handler earlier in this file, this
5654 + // one had neither the builder allowlist nor a containment check.
5655 + $safe_path = wdesignkit_widget_path_guard( $builder, $folder_name, $file_name );
5656 + if ( false === $safe_path || ! wdesignkit_path_inside_builder_dir( $safe_path['dir'] ) ) {
5657 + wp_send_json(
5658 + (object) array(
5659 + 'success' => false,
5660 + 'message' => esc_html__( 'Operation Failed!', 'wdesignkit' ),
5661 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
5662 + )
5663 + );
5664 + wp_die();
5665 + }
4492 5666
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'] );
5667 + $builder = $safe_path['builder'];
5668 + $file_path = $safe_path['base'];
5669 +
5670 + $u_r_l = wp_json_file_decode( "$file_path.json" );
5671 +
5672 + if ( '' !== $img_ext ) {
5673 + $u_r_l->widget_data->widgetdata->w_image = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
5674 + $wp_filesystem->put_contents( "$file_path.$img_ext", $img_body['body'] );
5675 + }
5676 +
5677 + $wp_filesystem->put_contents( "$file_path.json", wp_json_encode( $u_r_l ) );
5678 + }
4495 5679 }
4496 5680
4497 5681 wp_send_json( $response );
4498 5682 wp_die();
@@ -4587,18 +5771,22 @@
4587 5771
4588 5772 $get_setting = get_option( 'wkit_settings_panel', false );
4589 5773
4590 5774 $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,
5775 + 'builder' => isset( $get_setting['builder'] ) ? $get_setting['builder'] : true,
5776 + 'template' => isset( $get_setting['template'] ) ? $get_setting['template'] : true,
5777 + 'gutenberg_builder' => isset( $get_setting['gutenberg_builder'] ) ? $get_setting['gutenberg_builder'] : true,
5778 + 'gutenberg_core_builder' => isset( $get_setting['gutenberg_core_builder'] ) ? $get_setting['gutenberg_core_builder'] : false,
5779 + 'elementor_builder' => isset( $get_setting['elementor_builder'] ) ? $get_setting['elementor_builder'] : true,
5780 + 'bricks_builder' => isset( $get_setting['bricks_builder'] ) ? $get_setting['bricks_builder'] : false,
5781 + 'gutenberg_template' => isset( $get_setting['gutenberg_template'] ) ? $get_setting['gutenberg_template'] : true,
5782 + 'elementor_template' => isset( $get_setting['elementor_template'] ) ? $get_setting['elementor_template'] : true,
5783 + 'code_snippet' => isset( $get_setting['code_snippet'] ) ? $get_setting['code_snippet'] : true,
5784 + 'cross_copy_paste' => isset( $get_setting['cross_copy_paste'] ) ? $get_setting['cross_copy_paste'] : false,
5785 + 'cross_copy_paste_elementor' => isset( $get_setting['cross_copy_paste_elementor'] ) ? $get_setting['cross_copy_paste_elementor'] : false,
5786 + 'cross_copy_paste_gutenberg' => isset( $get_setting['cross_copy_paste_gutenberg'] ) ? $get_setting['cross_copy_paste_gutenberg'] : false,
5787 + 'cross_copy_paste_bricks' => isset( $get_setting['cross_copy_paste_bricks'] ) ? $get_setting['cross_copy_paste_bricks'] : false,
5788 + 'plugin_version' => $version_check,
4601 5789 );
4602 5790
4603 5791 if ( isset( $get_setting['remove_db'] ) ) {
4604 5792 $setting_data['remove_db'] = $get_setting['remove_db'];
@@ -4651,9 +5839,9 @@
4651 5839 }
4652 5840
4653 5841 $get_updated_data = get_option( 'wkit_white_label', false );
4654 5842 $response = array(
4655 - 'message' => 'Data Added successfully',
5843 + 'message' => __( 'Data Added successfully', 'wdesignkit' ),
4656 5844 'success' => true,
4657 5845 'data' => $get_updated_data,
4658 5846 );
4659 5847
@@ -4700,21 +5888,21 @@
4700 5888 if ( ! empty( $response['data'] ) ) {
4701 5889 $response = json_decode( wp_json_encode( $response['data'] ), true );
4702 5890
4703 5891 if ( ! empty( $response['data']['tpae_licence'] ) && is_serialized( $response['data']['tpae_licence'] ) ) {
4704 - $response['data']['tpae_licence'] = unserialize( $response['data']['tpae_licence'] );
5892 + $response['data']['tpae_licence'] = unserialize( $response['data']['tpae_licence'], array( 'allowed_classes' => false ) );
4705 5893 }
4706 5894
4707 5895 if ( ! empty( $response['data']['tpag_licence'] ) && is_serialized( $response['data']['tpag_licence'] ) ) {
4708 - $response['data']['tpag_licence'] = unserialize( $response['data']['tpag_licence'] );
5896 + $response['data']['tpag_licence'] = unserialize( $response['data']['tpag_licence'], array( 'allowed_classes' => false ) );
4709 5897 }
4710 5898
4711 5899 if ( ! empty( $response['data']['uichemy_licence'] ) && is_serialized( $response['data']['uichemy_licence'] ) ) {
4712 - $response['data']['uichemy_licence'] = unserialize( $response['data']['uichemy_licence'] );
5900 + $response['data']['uichemy_licence'] = unserialize( $response['data']['uichemy_licence'], array( 'allowed_classes' => false ) );
4713 5901 }
4714 5902
4715 5903 if ( ! empty( $response['data']['wdkit_licence'] ) && is_serialized( $response['data']['wdkit_licence'] ) ) {
4716 - $response['data']['wdkit_licence'] = unserialize( $response['data']['wdkit_licence'] );
5904 + $response['data']['wdkit_licence'] = unserialize( $response['data']['wdkit_licence'], array( 'allowed_classes' => false ) );
4717 5905
4718 5906 // Store WDesignKit license status locally for quick access
4719 5907 if ( ! empty( $response['data']['wdkit_licence'] ) && is_array( $response['data']['wdkit_licence'] ) ) {
4720 5908 update_option( 'wdkit_licence_data', $response['data']['wdkit_licence'] );
@@ -4721,9 +5909,9 @@
4721 5909 }
4722 5910 }
4723 5911
4724 5912 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'] );
5913 + $response['data']['wdkit_licence_extra'] = unserialize( $response['data']['wdkit_licence_extra'], array( 'allowed_classes' => false ) );
4726 5914 }
4727 5915 }
4728 5916
4729 5917 wp_send_json( $response );
@@ -4766,12 +5954,16 @@
4766 5954 */
4767 5955 protected function wdkit_sync_licence_key() {
4768 5956 $token = ! empty( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
4769 5957 $licencename = ! empty( $_POST['licencename'] ) ? sanitize_text_field( wp_unslash( $_POST['licencename'] ) ) : '';
5958 + // Needed to identify which extra-credit key to sync (wdkit_extra / wdkit_ai_extra
5959 + // are arrays matched by the api key's last digits on the server).
5960 + $apikey = ! empty( $_POST['apikey'] ) ? sanitize_text_field( wp_unslash( $_POST['apikey'] ) ) : '';
4770 5961
4771 5962 $args = array(
4772 5963 'token' => $token,
4773 5964 'licencename' => $licencename,
5965 + 'apikey' => $apikey,
4774 5966 );
4775 5967
4776 5968 $response = $this->wkit_api_call( $args, 'licence_sync' );
4777 5969
@@ -4904,9 +6096,9 @@
4904 6096 $token = $this->wdkit_login_user_token( $email );
4905 6097 $args = array( 'token' => $token );
4906 6098
4907 6099 if ( 'session' !== $logout_type ) {
4908 - delete_transient( 'wdkit_auth_' . $email );
6100 + delete_transient( 'wdkit_auth_' . wdesignkit_cloud_session_key( $email ) );
4909 6101 // Clear stored license data on logout so banner shows again
4910 6102 delete_option( 'wdkit_licence_data' );
4911 6103 $response = WDesignKit_Data_Query::get_data( 'logout', $args );
4912 6104 }
@@ -4926,9 +6118,9 @@
4926 6118 */
4927 6119 protected function wdkit_login_user_token( $email = '' ) {
4928 6120
4929 6121 if ( ! empty( $email ) ) {
4930 - $user_key = strstr( $email, '@', true );
6122 + $user_key = wdesignkit_cloud_session_key( $email );
4931 6123 $get_login = get_transient( 'wdkit_auth_' . $user_key );
4932 6124
4933 6125 if ( ! empty( $get_login ) && ! empty( $get_login['token'] ) ) {
4934 6126 return $get_login['token'];
@@ -4946,9 +6138,9 @@
4946 6138 * @param string $data send all post data.
4947 6139 * @param string $type store text data.
4948 6140 * @param string $condition store text data.
4949 6141 */
4950 - protected function wdkit_sanitizer_bypass( $data, $type, $condition = 'none' ) {
6142 + protected function wdkit_extract_post_field( $data, $type, $condition = 'none' ) {
4951 6143
4952 6144 if ( 'none' === $condition ) {
4953 6145 return $data[ $type ];
4954 6146 } elseif ( 'cr_widget' === $condition ) {
@@ -4953,8 +6145,10 @@
4953 6145 return $data[ $type ];
4954 6146 } elseif ( 'cr_widget' === $condition ) {
4955 6147 return $data[ $type ];
4956 6148 }
6149 +
6150 + return null;
4957 6151 }
4958 6152
4959 6153
4960 6154 /**