PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / trunk
WDesignKit – AI Templates, Widget Builder & MCP Workflow vtrunk
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 +1459 -159 2.5.3trunk 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':
@@ -682,15 +766,22 @@
682 766 $nexter_active_check = is_plugin_active( 'the-plus-addons-for-block-editor/the-plus-addons-for-block-editor.php' );
683 767
684 768 $theplus_licence = get_option( 'tpaep_licence_data', array() );
685 769
686 - 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 ) ) {
687 775 $manage_licence['tpae'] = $theplus_licence;
688 776 }
689 777
690 778 $nexter_licence = get_option( 'tpgb_activate', array() );
691 779
692 - 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'] ) ) {
693 784 $tpgb_license_status = get_option( 'tpgbp_license_status', array() );
694 785 $tpgb_license_status['license_key'] = $nexter_licence['tpgb_activate_key'];
695 786 $manage_licence['tpag'] = $tpgb_license_status;
696 787 }
@@ -731,14 +822,24 @@
731 822 'site_url' => $site_url,
732 823 );
733 824
734 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 +
735 836 $status = ( ! empty( $response['status'] ) ) ? sanitize_text_field( $response['status'] ) : 'error';
736 837 $email = isset( $_POST['email'] ) ? strtolower( sanitize_email( wp_unslash( $_POST['email'] ) ) ) : false;
737 838
738 839 /**Condtion user for user logout & expire token*/
739 840 if ( 'Token is Expired' === $status || 'Authorization Token not found' === $status ) {
740 - delete_transient( 'wdkit_auth_' . $email );
841 + delete_transient( 'wdkit_auth_' . wdesignkit_cloud_session_key( $email ) );
741 842 // Clear stored license data when token expires so banner shows again
742 843 delete_option( 'wdkit_licence_data' );
743 844 }
744 845
@@ -750,9 +851,9 @@
750 851 if ( ! empty( $response['credits']['wdkit_licence'] ) && is_array( $response['credits']['wdkit_licence'] ) ) {
751 852 $wdkit_licence = $response['credits']['wdkit_licence'];
752 853 // Handle serialized data
753 854 if ( is_string( $wdkit_licence ) && is_serialized( $wdkit_licence ) ) {
754 - $wdkit_licence = unserialize( $wdkit_licence );
855 + $wdkit_licence = unserialize( $wdkit_licence, array( 'allowed_classes' => false ) );
755 856 }
756 857 if ( ! empty( $wdkit_licence ) && is_array( $wdkit_licence ) ) {
757 858 update_option( 'wdkit_licence_data', $wdkit_licence );
758 859 }
@@ -783,8 +884,16 @@
783 884 $credits = ! empty( $response['credits']['widget_limit']['meta_value'] ) ? $response['credits']['widget_limit']['meta_value'] : 10;
784 885 $server_list = ! empty( $response['widgettemplate'] ) ? $response['widgettemplate'] : array();
785 886 $db_builder_list = ! empty( $response['widgetbuilder'] ) ? $response['widgetbuilder'] : array();
786 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 +
787 896 $placeholderimg = WDKIT_URL . 'assets/images/placeholder.jpg';
788 897
789 898 $local_list = $this->wdkit_get_local_widgets();
790 899
@@ -870,13 +979,31 @@
870 979 );
871 980 }
872 981 }
873 982
874 - $get_db_widget = get_option( 'wkit_deactivate_widgets', array() );
875 - if ( empty( $get_db_widget ) ) {
876 - add_option( 'wkit_deactivate_widgets', $db_widget, '', 'yes' );
877 - } else {
878 - 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 + }
879 1006 }
880 1007
881 1008 return $final;
882 1009 }
@@ -890,8 +1017,16 @@
890 1017 $args = $this->wdkit_parse_args( $_POST );
891 1018
892 1019 $response = WDesignKit_Data_Query::get_data( 'browse_page', $args );
893 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 +
894 1029 $manage_licence = array();
895 1030 $manage_licence['theplus_elementor_addon'] = ! empty( defined( 'THEPLUS_VERSION' ) ) ? true : false;
896 1031 $manage_licence['tpag'] = ! empty( defined( 'TPGBP_VERSION' ) ) ? true : false;
897 1032 $manage_licence['elementor-pro'] = ! empty( defined( 'ELEMENTOR_PRO_VERSION' ) ) ? true : false;
@@ -1005,8 +1140,24 @@
1005 1140 }
1006 1141
1007 1142 $response = WDesignKit_Data_Query::get_data( 'save_template', $args );
1008 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 +
1009 1160 wp_send_json( $response );
1010 1161 wp_die();
1011 1162 }
1012 1163
@@ -1030,9 +1181,11 @@
1030 1181 'success' => false,
1031 1182 );
1032 1183 } else {
1033 1184 $temp_content = str_replace( '\\', '', $temp_content );
1034 - $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 );
1035 1188 $temp_content = base64_encode( $temp_content );
1036 1189
1037 1190 $args = array(
1038 1191 'token' => $token,
@@ -1070,8 +1223,13 @@
1070 1223 *
1071 1224 * @since 2.3.3
1072 1225 */
1073 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 +
1074 1232 $image_url = isset( $_POST['image'] ) ? sanitize_text_field( $_POST['image'] ) : '';
1075 1233
1076 1234 if ( empty( $image_url ) ) {
1077 1235 $response = array(
@@ -1090,14 +1248,26 @@
1090 1248 'success' => false,
1091 1249 );
1092 1250 } else {
1093 1251 $saved_url = wp_get_attachment_url( $attachment_id );
1094 -
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 +
1095 1265 $response = array(
1096 1266 'message' => __( 'Image Saved', 'wdesignkit' ),
1097 1267 'description' => __( 'Image successfully saved to Media Library.', 'wdesignkit' ),
1098 1268 'success' => true,
1099 - 'url' => $saved_url,
1269 + 'url' => $saved_url,
1100 1270 );
1101 1271 }
1102 1272
1103 1273 }
@@ -1111,8 +1281,235 @@
1111 1281 * Get Elementor Global color and Typography.
1112 1282 *
1113 1283 * @since 1.1.16
1114 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 +
1115 1512 protected function wdkit_get_global_val() {
1116 1513
1117 1514 $builder = isset( $_POST['builder'] ) ? strtolower( sanitize_text_field( $_POST['builder'] ) ) : '';
1118 1515
@@ -1854,8 +2251,13 @@
1854 2251 $site_data = ! empty( $_POST['site_data'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['site_data'] ) ), true ) : array();
1855 2252
1856 2253 if ( 'elementor' == $builder ) {
1857 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 +
1858 2260 if ( ! $kit_id ) {
1859 2261 $response = array(
1860 2262 'message' => __( 'Elementor kit not found', 'wdesignkit' ),
1861 2263 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
@@ -1865,18 +2267,13 @@
1865 2267 wp_send_json( $response );
1866 2268 wp_die();
1867 2269 }
1868 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.
1869 2273 $kit_meta = get_post_meta( $kit_id, '_elementor_page_settings', true );
1870 - if ( empty( $kit_meta ) ) {
1871 - $response = array(
1872 - 'message' => __( 'Data Not Found', 'wdesignkit' ),
1873 - 'description' => __( 'No site data found in kit', 'wdesignkit' ),
1874 - 'success' => false,
1875 - );
1876 -
1877 - wp_send_json( $response );
1878 - wp_die();
2274 + if ( ! is_array( $kit_meta ) ) {
2275 + $kit_meta = array();
1879 2276 }
1880 2277
1881 2278 $kit_meta['container_width'] = ! empty( $site_data['container_width'] ) ? $site_data['container_width'] : array();
1882 2279 $kit_meta['__globals__'] = ! empty( $site_data['globals'] ) ? $site_data['globals'] : array();
@@ -1883,8 +2280,13 @@
1883 2280 $kit_meta['body_background_color'] = ! empty( $site_data['body_background_color'] ) ? $site_data['body_background_color'] : array();
1884 2281
1885 2282 update_post_meta( $kit_id, '_elementor_page_settings', $kit_meta );
1886 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 +
1887 2289 $response = array(
1888 2290 'message' => __( 'Site data Updated', 'wdesignkit' ),
1889 2291 'description' => __( 'Site Globals Updated', 'wdesignkit' ),
1890 2292 'success' => true,
@@ -1932,8 +2334,16 @@
1932 2334 $g_typo = ! empty( $_POST['g_typography'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['g_typography'] ) ), true ) : array();
1933 2335
1934 2336 // Get colors from Elementor Site Kit
1935 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 +
1936 2346 if ( ! $kit_id ) {
1937 2347 $response = array(
1938 2348 'message' => __( 'Elementor kit not found', 'wdesignkit' ),
1939 2349 'description' => __( 'No active Elementor kit found', 'wdesignkit' ),
@@ -1943,25 +2353,25 @@
1943 2353 wp_send_json( $response );
1944 2354 wp_die();
1945 2355 }
1946 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.
1947 2359 $kit_meta = get_post_meta( $kit_id, '_elementor_page_settings', true );
1948 - if ( empty( $kit_meta ) ) {
1949 - $response = array(
1950 - 'message' => __( 'Data Not Found', 'wdesignkit' ),
1951 - 'description' => __( 'No meta data found in kit', 'wdesignkit' ),
1952 - 'success' => false,
1953 - );
1954 -
1955 - wp_send_json( $response );
1956 - wp_die();
2360 + if ( ! is_array( $kit_meta ) ) {
2361 + $kit_meta = array();
1957 2362 }
1958 2363
1959 - $kit_meta['custom_colors'] = array_merge( $g_color, $kit_meta['custom_colors'] );
1960 - $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() );
1961 2366
1962 2367 update_post_meta( $kit_id, '_elementor_page_settings', $kit_meta );
1963 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 +
1964 2374 $response = array(
1965 2375 'message' => __( 'Global data Updated', 'wdesignkit' ),
1966 2376 'description' => __( 'Global Color and Typography Updated', 'wdesignkit' ),
1967 2377 'success' => true,
@@ -1996,9 +2406,29 @@
1996 2406 wp_die();
1997 2407 }
1998 2408
1999 2409 /**
2410 + * Regenerate Elementor's cached CSS files after the active kit's
2411 + * `_elementor_page_settings` meta has been changed directly.
2000 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 + *
2001 2431 * Create Gutenberg page and save for re-generate css file.
2002 2432 *
2003 2433 * @since 1.2.3
2004 2434 */
@@ -2362,34 +2792,56 @@
2362 2792 $error_message = $response->get_error_message();
2363 2793
2364 2794 $result = $this->tpae_set_response( false, 'oops', 'oops', '' );
2365 2795 } else {
2366 - $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' ) ) );
2367 2802 $theme_name = $theme_info->name;
2368 2803 $theme_zip_url = $theme_info->download_link;
2369 2804
2370 - global $wp_filesystem;
2371 - // Install the theme
2372 - $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 + }
2373 2815
2374 2816 if ( ! function_exists( 'WP_Filesystem' ) ) {
2375 2817 require_once wp_normalize_path( ABSPATH . '/wp-admin/includes/file.php' );
2376 2818 }
2377 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 +
2378 2823 WP_Filesystem();
2379 2824
2380 2825 $active_theme = wp_get_theme();
2381 2826 $theme_name = $active_theme->get( 'Name' );
2382 2827
2383 - $wp_filesystem->put_contents( WP_CONTENT_DIR . '/themes/' . $theme_slug . '.zip', $theme['body'] );
2384 - $zip = new ZipArchive();
2385 - if ( $zip->open( WP_CONTENT_DIR . '/themes/' . $theme_slug . '.zip' ) === true ) {
2386 - $zip->extractTo( WP_CONTENT_DIR . '/themes/' );
2387 - $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 + );
2388 2842 }
2389 2843
2390 - $wp_filesystem->delete( WP_CONTENT_DIR . '/themes/' . $theme_slug . '.zip' );
2391 -
2392 2844 $activate_result = switch_theme( $name );
2393 2845
2394 2846 if ( ! is_wp_error( $activate_result ) ) {
2395 2847 $response = array(
@@ -2566,8 +3018,17 @@
2566 3018
2567 3019 unset( $args['email'] );
2568 3020 $args['unique_id'] = get_option( 'wdkit_unique_id' ) ?? '';
2569 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 +
2570 3031 $custom_meta = isset( $_POST['custom_meta'] ) ? sanitize_text_field( wp_unslash( $_POST['custom_meta'] ) ) : false;
2571 3032
2572 3033 /** Custom meta Field */
2573 3034 if ( ! empty( $custom_meta ) && 'true' === $custom_meta && ! empty( $response ) && ! empty( $response['content'] ) ) {
@@ -2578,9 +3039,9 @@
2578 3039
2579 3040 if ( ! empty( $meta_data ) ) {
2580 3041 foreach ( $meta_data as $meta_key => $meta_val ) {
2581 3042 if ( ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
2582 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
3043 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
2583 3044 }
2584 3045
2585 3046 if ( get_post_meta( get_the_ID(), $meta_key, true ) === '' ) {
2586 3047 add_post_meta( get_the_ID(), $meta_key, $meta_val[0] );
@@ -2591,8 +3052,34 @@
2591 3052 }
2592 3053 }
2593 3054 }
2594 3055
3056 + /**
3057 + * Fires after a template has been imported from the cloud.
3058 + *
3059 + * WDesignKit's templates live in the cloud, so nothing local records that an import
3060 + * happened — there is no post type, no option, nothing to count after the fact. This is the
3061 + * only moment the information exists.
3062 + *
3063 + * @since 2.6.4
3064 + *
3065 + * @param string $kind 'single' or 'kit'.
3066 + * @param string $builder Builder the template was imported for, e.g. 'elementor'.
3067 + * @param int $count How many templates this import brought in.
3068 + */
3069 + // Only a completed import counts. The cloud's failure shape for this endpoint family sets
3070 + // content => 'error' (see the sibling check in wdkit_import_kit_template() above) — that is
3071 + // non-empty, so the previous `||` fired the counter on failed imports too. Require success
3072 + // AND an absent/non-'error' content instead.
3073 + if ( ! empty( $response['success'] ) && ( ! isset( $response['content'] ) || 'error' !== $response['content'] ) ) {
3074 + do_action(
3075 + 'wdkit_template_imported',
3076 + 'import_kit_template' === $api_type ? 'kit' : 'single',
3077 + isset( $_POST['builder'] ) ? sanitize_key( wp_unslash( $_POST['builder'] ) ) : '',
3078 + 1
3079 + );
3080 + }
3081 +
2595 3082 wp_send_json( $response );
2596 3083 wp_die();
2597 3084 }
2598 3085
@@ -2603,8 +3090,265 @@
2603 3090 *
2604 3091 * @param array $content store media content.
2605 3092 * @param string $editor it is check editor.
2606 3093 */
3094 + /**
3095 + * Resolve a local upload URL back to its attachment ID.
3096 + *
3097 + * Handles the "-scaled" copy WordPress makes for large originals and any
3098 + * "-1920x1280" size suffix, both of which attachment_url_to_postid() misses because
3099 + * they are not the value stored in _wp_attached_file.
3100 + *
3101 + * @since 2.6.2
3102 + *
3103 + * @param string $url Local upload URL.
3104 + * @return int Attachment ID, or 0.
3105 + */
3106 + private static function wdkit_attachment_id_from_url( $url ) {
3107 + static $cache = array();
3108 +
3109 + if ( isset( $cache[ $url ] ) ) {
3110 + return $cache[ $url ];
3111 + }
3112 +
3113 + $id = (int) attachment_url_to_postid( $url );
3114 +
3115 + if ( ! $id ) {
3116 + // Try the original file behind a -scaled or -WxH derivative.
3117 + $stripped = preg_replace( '/-scaled(\.[a-z0-9]+)$/i', '$1', $url );
3118 + $stripped = preg_replace( '/-\d+x\d+(\.[a-z0-9]+)$/i', '$1', (string) $stripped );
3119 +
3120 + if ( $stripped && $stripped !== $url ) {
3121 + $id = (int) attachment_url_to_postid( $stripped );
3122 + }
3123 + }
3124 +
3125 + // Only remember hits. Page imports run concurrently, so an attachment created by a
3126 + // sibling request may not exist yet when this is first asked — caching that miss
3127 + // would keep every later control in this request pointing at nothing.
3128 + if ( $id ) {
3129 + $cache[ $url ] = $id;
3130 + }
3131 +
3132 + return $id;
3133 + }
3134 +
3135 + /**
3136 + * Is this media reference still pointing off-site?
3137 + *
3138 + * Template content arrives holding the URLs of wherever the media lived before. Those
3139 + * carry that site's attachment IDs, which have no meaning here - and can collide with
3140 + * unrelated local posts.
3141 + *
3142 + * @since 2.6.2
3143 + *
3144 + * @param string $url URL from a media control.
3145 + * @return bool True when the URL points at another site's uploads.
3146 + */
3147 + private static function wdkit_is_foreign_media_url( $url ) {
3148 +
3149 + if ( ! class_exists( 'Wdkit_Image_Guard' ) ) {
3150 + require_once WDKIT_INCLUDES . 'admin/class-wdkit-image-guard.php';
3151 + }
3152 +
3153 + $uploads = wp_get_upload_dir();
3154 +
3155 + return Wdkit_Image_Guard::is_foreign_media( $url, isset( $uploads['baseurl'] ) ? $uploads['baseurl'] : '' );
3156 + }
3157 +
3158 + /**
3159 + * Find - or make - the local attachment behind a source-site media URL.
3160 + *
3161 + * Elementor stamps every image it imports with `_elementor_source_image_hash`
3162 + * (sha1 of the URL it came from), and its importer consults that before doing any
3163 + * network work. Delegating here means a URL already imported at create time resolves
3164 + * from the database, and one that never made it is fetched exactly once.
3165 + *
3166 + * Only ever called for foreign URLs. Handing it a local URL would re-download the
3167 + * file and leave a duplicate, because the stored hash is of the *remote* URL and so
3168 + * would never match.
3169 + *
3170 + * @since 2.6.2
3171 + *
3172 + * @param string $url Source-site media URL.
3173 + * @param int $source_id The source site's attachment ID, used as Elementor's cache key.
3174 + * @return array Local `id` and `url`, or an empty array when it cannot be resolved.
3175 + */
3176 + private static function wdkit_localise_media_url( $url, $source_id = 0 ) {
3177 + static $cache = array();
3178 +
3179 + if ( isset( $cache[ $url ] ) ) {
3180 + return $cache[ $url ];
3181 + }
3182 +
3183 + if ( ! did_action( 'elementor/loaded' ) || ! class_exists( '\\Elementor\\Plugin' ) ) {
3184 + return array();
3185 + }
3186 +
3187 + $images = \Elementor\Plugin::$instance->templates_manager->get_import_images_instance();
3188 +
3189 + if ( ! $images ) {
3190 + return array();
3191 + }
3192 +
3193 + // A download may happen, so keep the oversized-image guard in force.
3194 + self::wdkit_guard_oversized_images();
3195 +
3196 + $imported = $images->import(
3197 + array(
3198 + // Elementor only checks its hash table when an id is present.
3199 + 'id' => $source_id ? $source_id : 1,
3200 + 'url' => $url,
3201 + )
3202 + );
3203 +
3204 + $local = ( ! empty( $imported['id'] ) && ! empty( $imported['url'] ) )
3205 + ? array(
3206 + 'id' => (int) $imported['id'],
3207 + 'url' => $imported['url'],
3208 + )
3209 + : array();
3210 +
3211 + // Remember hits only: a sibling request importing concurrently may simply not have
3212 + // finished yet, and caching that miss would strand every later control on this page.
3213 + if ( $local ) {
3214 + $cache[ $url ] = $local;
3215 + }
3216 +
3217 + return $local;
3218 + }
3219 +
3220 + /**
3221 + * Repair dangling attachment IDs across every page of a finished import.
3222 + *
3223 + * The create-time repair in wdkit_media_import() can only see attachments that already
3224 + * exist. Pages import concurrently and share images — an icon first imported by one
3225 + * page is referenced by several others — so a page that runs early legitimately cannot
3226 + * resolve an image a sibling request has not created yet.
3227 + *
3228 + * This runs at the finalize step, once every page and attachment exists, and fixes
3229 + * whatever the per-page pass had to leave behind.
3230 + *
3231 + * @since 2.6.2
3232 + *
3233 + * @param array $page_ids Imported post IDs.
3234 + * @return int Number of pages actually rewritten.
3235 + */
3236 + private function wdkit_sweep_attachment_ids( $page_ids ) {
3237 +
3238 + if ( empty( $page_ids ) || ! did_action( 'elementor/loaded' ) ) {
3239 + return 0;
3240 + }
3241 +
3242 + $fixed = 0;
3243 + $ids = array_unique( array_map( 'intval', $page_ids ) );
3244 +
3245 + // Primes the meta cache for the whole batch in one query, so the
3246 + // get_post_meta() call below hits the cache instead of issuing one query
3247 + // per imported page.
3248 + update_meta_cache( 'post', $ids );
3249 +
3250 + foreach ( $ids as $post_id ) {
3251 +
3252 + if ( ! $post_id ) {
3253 + continue;
3254 + }
3255 +
3256 + $raw = get_post_meta( $post_id, '_elementor_data', true );
3257 +
3258 + if ( empty( $raw ) ) {
3259 + continue;
3260 + }
3261 +
3262 + $data = is_array( $raw ) ? $raw : json_decode( $raw, true );
3263 +
3264 + if ( ! is_array( $data ) ) {
3265 + continue;
3266 + }
3267 +
3268 + $repaired = self::wdkit_repair_attachment_ids( $data );
3269 +
3270 + if ( wp_json_encode( $repaired ) === wp_json_encode( $data ) ) {
3271 + continue;
3272 + }
3273 +
3274 + // Save through the document API so Elementor regenerates the page CSS — the
3275 + // background-image rules are only emitted once the IDs resolve.
3276 + $document = \Elementor\Plugin::$instance->documents->get( $post_id );
3277 +
3278 + // Count only a save that actually happened. Document::save() returns false
3279 + // without saving when the current user cannot edit the post, and reporting
3280 + // those as repaired hides the fact that nothing changed.
3281 + if ( $document && $document->save( array( 'elements' => $repaired ) ) ) {
3282 + ++$fixed;
3283 + }
3284 + }
3285 +
3286 + if ( $fixed ) {
3287 + \Elementor\Plugin::$instance->files_manager->clear_cache();
3288 + }
3289 +
3290 + return $fixed;
3291 + }
3292 +
3293 + /**
3294 + * Repair media controls whose attachment ID does not resolve.
3295 + *
3296 + * Elementor media controls store `{ url, id }`. Controls flagged `has_sizes` — the
3297 + * container/section **background image** among them — do not render from `url` at all:
3298 + * CSS generation resolves the image through the attachment ID, so a dangling ID
3299 + * produces no `background-image` rule and the section renders with no image even
3300 + * though its URL is perfectly correct.
3301 + *
3302 + * IDs arrive dangling whenever Elementor's own importer does not rewrite a control —
3303 + * it carries the source site's ID, which means nothing locally. Now that the URL is
3304 + * already a local upload before import, the ID can simply be looked up from it.
3305 + *
3306 + * @since 2.6.2
3307 + *
3308 + * @param mixed $node Elementor data, walked recursively.
3309 + * @return mixed Data with resolvable attachment IDs.
3310 + */
3311 + private static function wdkit_repair_attachment_ids( $node ) {
3312 +
3313 + if ( ! is_array( $node ) ) {
3314 + return $node;
3315 + }
3316 +
3317 + // A media control value: has a url, and an id slot to correct.
3318 + if ( isset( $node['url'] ) && is_string( $node['url'] ) && array_key_exists( 'id', $node ) ) {
3319 +
3320 + $current = (int) $node['id'];
3321 + $is_live = $current && 'attachment' === get_post_type( $current );
3322 +
3323 + if ( self::wdkit_is_foreign_media_url( $node['url'] ) ) {
3324 + // Still pointing at the source site. Ask Elementor for the local copy: its
3325 + // _elementor_source_image_hash lookup returns the attachment the create-time
3326 + // import already made, so this normally costs a single query and no download.
3327 + $local = self::wdkit_localise_media_url( $node['url'], $current );
3328 +
3329 + if ( ! empty( $local['id'] ) && ! empty( $local['url'] ) ) {
3330 + $node['id'] = $local['id'];
3331 + $node['url'] = $local['url'];
3332 + }
3333 + } elseif ( ! $is_live && false !== strpos( $node['url'], '/wp-content/uploads/' ) ) {
3334 + $resolved = self::wdkit_attachment_id_from_url( $node['url'] );
3335 +
3336 + if ( $resolved ) {
3337 + $node['id'] = $resolved;
3338 + }
3339 + }
3340 + }
3341 +
3342 + foreach ( $node as $key => $value ) {
3343 + if ( is_array( $value ) ) {
3344 + $node[ $key ] = self::wdkit_repair_attachment_ids( $value );
3345 + }
3346 + }
3347 +
3348 + return $node;
3349 + }
3350 +
2607 3351 public function wdkit_media_import( $content = array(), $editor = '' ) {
2608 3352
2609 3353 if ( empty( $content ) && empty( $editor ) ) {
2610 3354 $args = $this->wdkit_parse_args( $_POST );
@@ -2624,8 +3368,9 @@
2624 3368 if ( ! class_exists( 'Wdkit_Import_Images' ) ) {
2625 3369 require_once WDKIT_INCLUDES . 'admin/class-wdkit-import-images.php';
2626 3370 }
2627 3371
3372 +
2628 3373 if ( ! empty( $args['editor'] ) && 'gutenberg' === $args['editor'] && ! empty( $content ) ) {
2629 3374 $media_import = array( $content );
2630 3375 $media_import = self::blocks_import_media_copy_content( $media_import );
2631 3376 $content = $media_import[0];
@@ -2633,8 +3378,13 @@
2633 3378 $media_import = array( $content );
2634 3379 $media_import = self::widgets_elements_id_change( $media_import );
2635 3380 $media_import = self::widgets_import_media_copy_content( $media_import );
2636 3381 $content = $media_import[0];
3382 +
3383 + // Last: point any control Elementor left holding a foreign attachment ID at the
3384 + // local attachment its URL already refers to. Without this, has_sizes controls
3385 + // such as container background images resolve to nothing and render empty.
3386 + $content = self::wdkit_repair_attachment_ids( $content );
2637 3387 }
2638 3388
2639 3389 return $content;
2640 3390 }
@@ -2705,9 +3455,13 @@
2705 3455 $control_type = \Elementor\Plugin::instance()->controls_manager->get_control( $get_control['type'] );
2706 3456 $control_name = $get_control['name'];
2707 3457
2708 3458 if ( ! $control_type ) {
2709 - return $get_element_instance;
3459 + // Skip just this control. Returning here would abandon every control after
3460 + // it, so a single unregistered type - routine when a kit uses an addon that
3461 + // is not fully active yet - would silently leave the rest of the element's
3462 + // media pointing at the source site.
3463 + continue;
2710 3464 }
2711 3465
2712 3466 if ( method_exists( $control_type, $tp_mi_on_fun ) ) {
2713 3467 $get_element_instance['settings'][ $control_name ] = $control_type->{$tp_mi_on_fun}( $element->get_settings( $control_name ), $get_control );
@@ -2782,73 +3536,298 @@
2782 3536 public static function blocks_data_instance( array $block_data, array $args = array(), $block_args = null ) {
2783 3537
2784 3538 if ( ( isset( $block_data['name'] ) && isset( $block_data['clientId'] ) && isset( $block_data['attributes'] ) ) || ( isset( $block_data['blockName'] ) && isset( $block_data['attrs'] ) && ! empty( $block_data['attrs'] ) ) ) {
2785 3539 $blocks_attr = isset( $block_data['attributes'] ) ? $block_data['attributes'] : ( isset( $block_data['attrs'] ) ? $block_data['attrs'] : array() );
2786 - foreach ( $blocks_attr as $block_key => $block_val ) {
2787 - if ( isset( $block_val['url'] ) && isset( $block_val['id'] ) && ! empty( $block_val['url'] ) ) {
2788 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $block_val );
2789 - $blocks_attr[ $block_key ] = $new_media;
2790 - } elseif ( isset( $block_val['url'] ) && ! empty( $block_val['url'] ) && preg_match( '/\.(jpg|png|jpeg|gif|svg|webp)$/', $block_val['url'] ) ) {
2791 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $block_val );
2792 - $blocks_attr[ $block_key ] = $new_media;
2793 - } elseif ( is_array( $block_val ) && ! empty( $block_val ) ) {
2794 - 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 ) ) {
2795 - foreach ( $block_val as $key => $val ) {
2796 - if ( is_array( $val ) && ! empty( $val ) ) {
3540 + $blocks_attr = self::wdkit_import_block_media( $blocks_attr );
3541 + if ( isset( $block_data['attributes'] ) ) {
3542 + $block_data['attributes'] = $blocks_attr;
3543 + } elseif ( isset( $block_data['attrs'] ) ) {
3544 + $block_data['attrs'] = $blocks_attr;
3545 + }
2797 3546
2798 - if ( isset( $val['url'] ) && ( isset( $val['Id'] ) || isset( $val['id'] ) ) && ! empty( $val['url'] ) ) {
2799 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $val );
2800 - $blocks_attr[ $block_key ][ $key ] = $new_media;
2801 - } elseif ( isset( $val['url'] ) && ! empty( $val['url'] ) && preg_match( '/\.(jpg|png|jpeg|gif|svg|webp)$/', $val['url'] ) ) {
2802 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $val );
2803 - $blocks_attr[ $block_key ][ $key ] = $new_media;
2804 - } else {
2805 - foreach ( $val as $sub_key => $sub_val ) {
2806 - if ( isset( $sub_val['url'] ) && ( isset( $sub_val['Id'] ) || isset( $sub_val['id'] ) ) && ! empty( $sub_val['url'] ) ) {
2807 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $sub_val );
3547 + $block_data = self::wdkit_relink_block_markup( $block_data );
3548 + }
2808 3549
2809 - if ( is_array( $sub_val ) && is_array( $new_media ) ) {
2810 - $blocks_attr[ $block_key ][ $key ][ $sub_key ] = array_merge( $sub_val, $new_media );
2811 - } else {
2812 - $blocks_attr[ $block_key ][ $key ][ $sub_key ] = $new_media;
2813 - }
2814 - } elseif ( isset( $sub_val['url'] ) && ! empty( $sub_val['url'] ) && preg_match( '/\.(jpg|png|jpeg|gif|svg|webp)$/', $sub_val['url'] ) ) {
2815 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $sub_val );
2816 - $blocks_attr[ $block_key ][ $key ][ $sub_key ] = $new_media;
2817 - } elseif ( is_array( $sub_val ) && ! empty( $sub_val ) ) {
2818 - foreach ( $sub_val as $sub_key1 => $sub_val1 ) {
2819 - if ( isset( $sub_val1['url'] ) && ( isset( $sub_val1['Id'] ) || isset( $sub_val1['id'] ) ) && ! empty( $sub_val1['url'] ) ) {
2820 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $sub_val1 );
3550 + return $block_data;
3551 + }
2821 3552
2822 - if ( is_array( $sub_val1 ) && is_array( $new_media ) ) {
2823 - $blocks_attr[ $block_key ][ $key ][ $sub_key ][ $sub_key1 ] = array_merge( $sub_val1, $new_media );
2824 - } else {
2825 - $blocks_attr[ $block_key ][ $key ][ $sub_key ][ $sub_key1 ] = $new_media;
2826 - }
2827 - } elseif ( isset( $sub_val1['url'] ) && ! empty( $sub_val1['url'] ) && preg_match( '/\.(jpg|png|jpeg|gif|svg|webp)$/', $sub_val1['url'] ) ) {
2828 - $new_media = Wdkit_Import_Images::wdkit_Import_media( $sub_val1 );
2829 - $blocks_attr[ $block_key ][ $key ][ $sub_key ][ $sub_key1 ] = $new_media;
2830 - }
2831 - }
2832 - }
2833 - }
2834 - }
2835 - }
2836 - }
2837 - }
3553 + /**
3554 + * Run block markup through the media import, the way the create path does.
3555 + *
3556 + * Used wherever block content is written from the browser: media import, then the Nexter
3557 + * block processor so each block's rendered copy matches its attributes, then serialise.
3558 + *
3559 + * @since 2.6.2
3560 + *
3561 + * @param string $content Block markup.
3562 + * @return string Block markup with local media.
3563 + */
3564 + private function wdkit_relink_gutenberg_content( $content ) {
3565 +
3566 + if ( ! is_string( $content ) || false === strpos( $content, '<!-- wp:' ) ) {
3567 + return $content;
3568 + }
3569 +
3570 + // wdkit_media_import() loads this itself, but it is referenced before that below.
3571 + if ( ! class_exists( 'Wdkit_Import_Images' ) ) {
3572 + require_once WDKIT_INCLUDES . 'admin/class-wdkit-import-images.php';
3573 + }
3574 +
3575 + // Thumbnail generation decodes each image, so keep the oversized-image guard in force.
3576 + self::wdkit_guard_oversized_images();
3577 +
3578 +
3579 + // Block attributes are JSON inside the block delimiters, so they only survive a parse
3580 + // when the string carries exactly one level of escaping. Arrive with an extra level and
3581 + // parse_blocks() reads no attributes at all - serialising that back out writes every
3582 + // block bare, throwing away titles, body text, icons and styling.
3583 + $parsable = self::wdkit_parsable_block_content( $content );
3584 +
3585 + if ( null === $parsable ) {
3586 +
3587 + return $content;
3588 + }
3589 +
3590 + $blocks = parse_blocks( $parsable );
3591 + $blocks = $this->wdkit_media_import( $blocks, 'gutenberg' );
3592 +
3593 + if ( empty( $blocks ) || ! is_array( $blocks ) ) {
3594 + return $content;
3595 + }
3596 +
3597 + if ( class_exists( 'WDKIT_Nexter_Block_Processor' ) ) {
3598 + $processor = new WDKIT_Nexter_Block_Processor();
3599 + $blocks = $processor->run( $blocks );
3600 + }
3601 +
3602 + $serialised = serialize_blocks( $blocks );
3603 +
3604 + // Last line of defence. This function exists to repoint media, so a result carrying
3605 + // fewer block attributes than it started with is a broken round trip, not a rewrite.
3606 + // Leaving the media wrong is recoverable; saving gutted content is not.
3607 + $before = self::wdkit_block_attr_count( $parsable );
3608 + $after = self::wdkit_block_attr_count( $serialised );
3609 +
3610 + if ( $after < $before ) {
3611 +
3612 + return $content;
3613 + }
3614 +
3615 + // Never hand back nothing: an empty result would blank the page.
3616 + return ! empty( $serialised ) ? $serialised : $content;
3617 + }
3618 +
3619 + /**
3620 + * Rebuild the block stylesheet for a page whose content we just rewrote.
3621 + *
3622 + * The addon keeps each block's styling in a generated per-page stylesheet, and every rule
3623 + * is keyed to the block id it was written for. That file is produced when the page is
3624 + * saved through the editor - not by wp_update_post() from an AJAX handler - so rewriting
3625 + * content here leaves the page pointing at a stylesheet built for the previous markup.
3626 + * Blocks whose ids are not in that file get no rules at all and render unstyled.
3627 + *
3628 + * @since 2.6.2
3629 + *
3630 + * @param int $post_id Page whose content changed.
3631 + * @return bool True when a rebuild was triggered.
3632 + */
3633 + private static function wdkit_rebuild_block_css( $post_id ) {
3634 +
3635 + if ( ! $post_id ) {
3636 + return false;
3637 + }
3638 +
3639 + foreach ( get_declared_classes() as $class ) {
3640 + if ( ! method_exists( $class, 'make_block_css_by_post_id' ) ) {
3641 + continue;
3642 + }
3643 +
3644 + try {
3645 + if ( method_exists( $class, 'instance' ) ) {
3646 + $instance = $class::instance();
3647 + } elseif ( method_exists( $class, 'get_instance' ) ) {
3648 + $instance = $class::get_instance();
3649 + } else {
3650 + $instance = new $class();
2838 3651 }
3652 +
3653 + $instance->make_block_css_by_post_id( $post_id );
3654 +
3655 +
3656 + return true;
3657 + } catch ( \Throwable $e ) {
3658 + // Styling is best-effort: a failure here must not fail the import.
3659 +
3660 + return false;
2839 3661 }
2840 - if ( isset( $block_data['attributes'] ) ) {
2841 - $block_data['attributes'] = $blocks_attr;
2842 - } elseif ( isset( $block_data['attrs'] ) ) {
2843 - $block_data['attrs'] = $blocks_attr;
3662 + }
3663 +
3664 + return false;
3665 + }
3666 +
3667 + /**
3668 + * How many block attributes does this markup actually yield when parsed?
3669 + *
3670 + * Used as a before/after measure: block attributes are the part of block markup a round
3671 + * trip can silently drop, so counting them is how we tell a rewrite from a mangling.
3672 + *
3673 + * @since 2.6.2
3674 + *
3675 + * @param string $content Block markup.
3676 + * @return int Total attributes across every block.
3677 + */
3678 + private static function wdkit_block_attr_count( $content ) {
3679 + $total = 0;
3680 +
3681 + $walk = function ( $blocks ) use ( &$walk, &$total ) {
3682 + foreach ( $blocks as $block ) {
3683 + if ( ! empty( $block['attrs'] ) && is_array( $block['attrs'] ) ) {
3684 + $total += count( $block['attrs'] );
3685 + }
3686 +
3687 + if ( ! empty( $block['innerBlocks'] ) ) {
3688 + $walk( $block['innerBlocks'] );
3689 + }
2844 3690 }
3691 + };
3692 +
3693 + $walk( parse_blocks( (string) $content ) );
3694 +
3695 + return $total;
3696 + }
3697 +
3698 + /**
3699 + * Return this content in a form whose block attributes actually parse.
3700 + *
3701 + * Content written straight to post_content never had to parse, so an extra level of
3702 + * escaping on the way in did no harm. Parsing it - which repointing media requires - makes
3703 + * that escaping fatal: `{\"Title\":\"…\"}` is not JSON, so every attribute is discarded.
3704 + *
3705 + * Rather than assume a slash depth, this measures: if stripping one level yields more
3706 + * attributes, the content was over-escaped and the stripped form is the real one.
3707 + *
3708 + * @since 2.6.2
3709 + *
3710 + * @param string $content Block markup as received.
3711 + * @return string|null Markup safe to parse, or null when no form of it parses.
3712 + */
3713 + private static function wdkit_parsable_block_content( $content ) {
3714 +
3715 + $as_is = self::wdkit_block_attr_count( $content );
3716 +
3717 + // Nothing claims to carry attributes, so there is nothing to lose either.
3718 + if ( false === strpos( $content, '{' ) ) {
3719 + return $content;
2845 3720 }
2846 3721
3722 + $stripped = wp_unslash( $content );
3723 + $stripped_attrs = self::wdkit_block_attr_count( $stripped );
3724 +
3725 + if ( $stripped_attrs > $as_is ) {
3726 + return $stripped;
3727 + }
3728 +
3729 + if ( $as_is > 0 ) {
3730 + return $content;
3731 + }
3732 +
3733 + // Neither form parses into attributes even though the markup contains JSON: better to
3734 + // leave the content exactly as it arrived than to rewrite it into something bare.
3735 + return null;
3736 + }
3737 +
3738 + /**
3739 + * Point a block's saved markup at the media that was just localised.
3740 + *
3741 + * A block stores a rendered copy of itself in `innerHTML` / `innerContent`, and for many
3742 + * blocks that copy is what the front end actually outputs. Importing the attributes alone
3743 + * therefore fixes the editor while leaving the page still loading from the site the
3744 + * template came from - and those hosts answer 403, so the image renders broken.
3745 + *
3746 + * @since 2.6.2
3747 + *
3748 + * @param array $block_data One parsed block.
3749 + * @return array The block with its markup repointed.
3750 + */
3751 + private static function wdkit_relink_block_markup( $block_data ) {
3752 +
3753 + $map = Wdkit_Import_Images::get_url_map();
3754 +
3755 + if ( empty( $map ) ) {
3756 + return $block_data;
3757 + }
3758 +
3759 + $from = array_keys( $map );
3760 + $to = array_values( $map );
3761 +
3762 + if ( ! empty( $block_data['innerHTML'] ) && is_string( $block_data['innerHTML'] ) ) {
3763 + $block_data['innerHTML'] = str_replace( $from, $to, $block_data['innerHTML'] );
3764 + }
3765 +
3766 + if ( ! empty( $block_data['innerContent'] ) && is_array( $block_data['innerContent'] ) ) {
3767 + foreach ( $block_data['innerContent'] as $index => $chunk ) {
3768 + if ( is_string( $chunk ) ) {
3769 + $block_data['innerContent'][ $index ] = str_replace( $from, $to, $chunk );
3770 + }
3771 + }
3772 + }
3773 +
2847 3774 return $block_data;
2848 3775 }
2849 3776
2850 3777 /**
3778 + * Import every media reference held in a block's attributes.
3779 + *
3780 + * Block attributes nest arbitrarily - a repeater of cards each with an image, responsive
3781 + * variants, nested inner settings - so this recurses rather than reaching a fixed number
3782 + * of levels down. The previous version was unrolled exactly four levels deep and also
3783 + * skipped any subtree carrying an `md` key, which meant anything below that simply kept
3784 + * the source site's URL and attachment ID and rendered as an empty placeholder.
3785 + *
3786 + * A node counts as media when it has a non-empty string `url` and either an id slot or a
3787 + * URL that names an image file. That pairing is what distinguishes a media control from
3788 + * a link, which also carries a `url`.
3789 + *
3790 + * @since 2.6.2
3791 + *
3792 + * @param mixed $node Block attributes, walked recursively.
3793 + * @return mixed Attributes with local media.
3794 + */
3795 + private static function wdkit_import_block_media( $node ) {
3796 +
3797 + if ( ! is_array( $node ) ) {
3798 + return $node;
3799 + }
3800 +
3801 + $url = isset( $node['url'] ) && is_string( $node['url'] ) ? $node['url'] : '';
3802 +
3803 + if ( '' !== $url
3804 + && ( array_key_exists( 'id', $node ) || array_key_exists( 'Id', $node )
3805 + || preg_match( '/\.(?:jpe?g|png|gif|svg|webp|avif|bmp)$/i', (string) wp_parse_url( $url, PHP_URL_PATH ) ) )
3806 + ) {
3807 + $imported = Wdkit_Import_Images::wdkit_Import_media( $node );
3808 +
3809 + // Only accept a real result. The importer returns the node untouched when it
3810 + // cannot localise the file, and anything falsy here would wipe out the URL and
3811 + // leave the block with no image at all.
3812 + if ( ! empty( $imported['url'] ) ) {
3813 + $node = array_merge( $node, $imported );
3814 + }
3815 + }
3816 +
3817 + // Keep walking even after importing this node. A media value carries its own `sizes`
3818 + // map of per-size URLs, and returning here left every one of those pointing at the
3819 + // site the template came from - which is what the widgets actually render from.
3820 + foreach ( $node as $key => $value ) {
3821 + if ( is_array( $value ) ) {
3822 + $node[ $key ] = self::wdkit_import_block_media( $value );
3823 + }
3824 + }
3825 +
3826 + return $node;
3827 + }
3828 +
3829 + /**
2851 3830 * Kit Template Import Pages/Sections
2852 3831 *
2853 3832 * @since 1.0.0
2854 3833 * */
@@ -2927,8 +3906,28 @@
2927 3906 $output['description'] = $response['description'];
2928 3907 $output['data'] = $result;
2929 3908 $output['success'] = $response['success'];
2930 3909
3910 + // Counts the IMPORT ACTION, not what it brought in. A kit import always counts as 1 kit,
3911 + // no matter how many blocks/pages that kit contains — confirmed live: a single gutenberg
3912 + // kit import recorded total=680, kinds.kit=680, because $template_ids for that call was a
3913 + // 680-element array of the kit's own blocks and count( $template_ids ) counted every one of
3914 + // them. A page-kit's *size* is not tracking's concern; "was a kit imported" is.
3915 + //
3916 + // The 'single' branch keeps a defensive fallback for the one shape this endpoint's own
3917 + // $template_ids reliably takes when it is not a kit — a single {id, name, slug, thumb...}
3918 + // object — where count() would likewise count JSON keys instead of "1 template imported".
3919 + if ( ! empty( $output['success'] ) ) {
3920 + $is_kit = ( '' !== $website_kit );
3921 + $import_count = $is_kit ? 1 : ( isset( $template_ids['id'] ) ? 1 : ( is_array( $template_ids ) ? count( $template_ids ) : 1 ) );
3922 + do_action(
3923 + 'wdkit_template_imported',
3924 + $is_kit ? 'kit' : 'single',
3925 + sanitize_key( $builder ),
3926 + $import_count
3927 + );
3928 + }
3929 +
2931 3930 wp_send_json( $output );
2932 3931 wp_die();
2933 3932 }
2934 3933
@@ -3035,9 +4034,17 @@
3035 4034 );
3036 4035
3037 4036 $response = WDesignKit_Data_Query::get_data( $api_type, $temp_args );
3038 4037
3039 - if ( 'error' === $response['content'] ) {
4038 + if ( is_wp_error( $response ) ) {
4039 + wp_send_json( array(
4040 + 'success' => false,
4041 + 'message' => $response->get_error_message(),
4042 + ) );
4043 + wp_die();
4044 + }
4045 +
4046 + if ( isset( $response['content'] ) && 'error' === $response['content'] ) {
3040 4047 wp_send_json( $response );
3041 4048 wp_die();
3042 4049 }
3043 4050
@@ -3105,8 +4112,14 @@
3105 4112 }
3106 4113
3107 4114 $document = \Elementor\Plugin::$instance->documents->get($template_id);
3108 4115
4116 + // This saves content posted straight from the browser, which carries local image
4117 + // URLs but still the source template's attachment IDs. Without repairing them the
4118 + // save undoes what wdkit_media_import() fixed on create, and has_sizes controls —
4119 + // container background images especially — resolve to nothing and render empty.
4120 + $content = self::wdkit_repair_attachment_ids( $content );
4121 +
3109 4122 $document->save([
3110 4123 'elements' => $content
3111 4124 ]);
3112 4125 }
@@ -3111,8 +4124,94 @@
3111 4124 ]);
3112 4125 }
3113 4126
3114 4127 /**
4128 + * Update the content of an already-created page.
4129 + *
4130 + * Used by the async ("Site Ready first") import path: pages are created up front with
4131 + * their un-rewritten template content, then this writes the AI-rewritten content into
4132 + * each page in the background. Elementor saves via the document API (same as
4133 + * wkit_update_elementor_template); Gutenberg writes post_content directly.
4134 + *
4135 + * @since 2.6.2
4136 + */
4137 + protected function wdkit_update_page_content() {
4138 + $post_id = isset( $_POST['post_id'] ) ? intval( $_POST['post_id'] ) : 0;
4139 + $builder = isset( $_POST['builder'] ) ? sanitize_text_field( wp_unslash( $_POST['builder'] ) ) : '';
4140 +
4141 + if ( ! $post_id || ! current_user_can( 'edit_post', $post_id ) ) {
4142 + return array(
4143 + 'success' => false,
4144 + 'message' => esc_html__( 'Invalid page or insufficient permission', 'wdesignkit' ),
4145 + );
4146 + }
4147 +
4148 + if ( 'gutenberg' === $builder ) {
4149 + // Do NOT run kses here: Gutenberg block delimiters are HTML comments
4150 + // (<!-- wp:... -->) which kses strips. Mirror the create path, which stores
4151 + // the block markup slashed and unfiltered (endpoint is manage_options-gated
4152 + // and the content is plugin-generated).
4153 + $content = isset( $_POST['content'] ) ? wp_unslash( $_POST['content'] ) : '';
4154 +
4155 + // This content comes straight from the browser and still carries the template
4156 + // site's media URLs and attachment IDs, so it has to go through the same pipeline
4157 + // the create path uses. Without this the save simply undid the import: the files
4158 + // were fetched, then overwritten by a copy still pointing at the source site.
4159 + //
4160 + // Re-running is cheap. Every URL already handled resolves from the source-hash
4161 + // lookup, and media that is already local resolves straight from its URL, so no
4162 + // image is fetched or stored twice.
4163 + $content = $this->wdkit_relink_gutenberg_content( $content );
4164 +
4165 + $result = wp_update_post(
4166 + array(
4167 + 'ID' => $post_id,
4168 + 'post_content' => wp_slash( $content ),
4169 + ),
4170 + true
4171 + );
4172 +
4173 + if ( is_wp_error( $result ) ) {
4174 + return array(
4175 + 'success' => false,
4176 + 'message' => $result->get_error_message(),
4177 + );
4178 + }
4179 +
4180 + self::wdkit_rebuild_block_css( $post_id );
4181 + } else {
4182 + $elements = isset( $_POST['content'] ) ? json_decode( wp_unslash( $_POST['content'] ), true ) : array();
4183 +
4184 + if ( ! class_exists( '\\Elementor\\Plugin' ) ) {
4185 + return array(
4186 + 'success' => false,
4187 + 'message' => esc_html__( 'Elementor not available', 'wdesignkit' ),
4188 + );
4189 + }
4190 +
4191 + $document = \Elementor\Plugin::$instance->documents->get( $post_id );
4192 + if ( ! $document ) {
4193 + return array(
4194 + 'success' => false,
4195 + 'message' => esc_html__( 'Elementor document not found', 'wdesignkit' ),
4196 + );
4197 + }
4198 +
4199 + // Same as wkit_update_elementor_template(): browser-posted content keeps the
4200 + // source template's attachment IDs, so repair them or this save undoes the
4201 + // create-time fix and background images stop rendering.
4202 + $elements = self::wdkit_repair_attachment_ids( $elements );
4203 +
4204 + $document->save( array( 'elements' => $elements ) );
4205 + }
4206 +
4207 + return array(
4208 + 'success' => true,
4209 + 'message' => esc_html__( 'Page content updated', 'wdesignkit' ),
4210 + );
4211 + }
4212 +
4213 + /**
3115 4214 * Import single template and section from plugin only
3116 4215 *
3117 4216 * @param array $args store data.
3118 4217 * @param array $template_id store data.
@@ -3120,8 +4219,21 @@
3120 4219 * @param array $temp_data store data.
3121 4220 * */
3122 4221 protected function import_page_section_content() {
3123 4222
4223 + // Elementor sideloads every image referenced by the page from inside this request.
4224 + // A single oversized source image decodes to more than the whole memory limit, so
4225 + // guard before any of that starts.
4226 + $this->wdkit_guard_oversized_images();
4227 +
4228 + // Sideloading images for image-heavy pages (wdkit_media_import → Imagick
4229 + // thumbnail generation per image) can exceed the default 30s execution
4230 + // limit and fatal the request mid-import. Give this single page import
4231 + // more headroom; harmless no-op where set_time_limit() is disabled.
4232 + if ( function_exists( 'set_time_limit' ) ) {
4233 + @set_time_limit( 120 );
4234 + }
4235 +
3124 4236 if ( isset( $_POST['args'] ) ) {
3125 4237 $args = ! empty( $_POST['args'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['args'] ) ), true ) : array();
3126 4238 }
3127 4239
@@ -3144,11 +4256,9 @@
3144 4256 if ( isset( $_POST['template_id'] ) ) {
3145 4257 $template_id = ! empty( $_POST['template_id'] ) ? json_decode( sanitize_text_field( wp_unslash( $_POST['template_id'] ), true ) ) : '';
3146 4258 }
3147 4259
3148 - if ( isset( $_POST['temp_type'] ) ) {
3149 - $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'normal';
3150 - }
4260 + $temp_type = isset( $_POST['temp_type'] ) ? sanitize_text_field( wp_unslash( $_POST['temp_type'] ) ) : 'normal';
3151 4261
3152 4262 if ( isset( $_POST['data'] ) ) {
3153 4263 $data = ! empty( $_POST['data'] ) ? json_decode( wp_unslash( $_POST['data'] ) ) : '';
3154 4264 }
@@ -3169,8 +4279,21 @@
3169 4279 }
3170 4280
3171 4281 if ( ! empty( $data ) && ! empty( $template_id ) && ! empty( $post_type ) && current_user_can( 'manage_options' ) ) {
3172 4282 $post_content = $data;
4283 + // Restore The Plus Addons' globals before the page is built, so the widgets'
4284 + // tp_global_preset references resolve as soon as it renders. Done here rather
4285 + // than in the save-template UI's confirmation dialog so that every import path
4286 + // - the library, the abilities, the theme builder - gets it.
4287 + if ( isset( $post_content->tp_globals ) && ! empty( $post_content->tp_globals ) ) {
4288 + $this->wdkit_merge_tp_globals(
4289 + json_decode( wp_json_encode( $post_content->tp_globals ), true ),
4290 + isset( $post_content->tp_global_refs )
4291 + ? json_decode( wp_json_encode( $post_content->tp_global_refs ), true )
4292 + : array()
4293 + );
4294 + }
4295 +
3173 4296 $post_title = isset( $post_content->title ) ? sanitize_text_field( $post_content->title ) : '';
3174 4297 $post_slug = isset( $post_content->slug ) ? sanitize_text_field( $post_content->slug ) : '';
3175 4298 $file_type = isset( $post_content->file_type ) ? sanitize_text_field( $post_content->file_type ) : '';
3176 4299 $content = isset( $post_content->content ) ? wp_slash( $post_content->content ) : '';
@@ -3180,9 +4303,9 @@
3180 4303 if ( empty( $content ) ) {
3181 4304 wp_send_json(
3182 4305 array(
3183 4306 'template_id' => $template_id,
3184 - 'message' => 'Content is Empty.',
4307 + 'message' => __( 'Content is Empty.', 'wdesignkit' ),
3185 4308 )
3186 4309 );
3187 4310 wp_die();
3188 4311 } elseif ( ! empty( $content ) && ! empty( $file_type ) && 'wp_block' === $file_type ) {
@@ -3188,27 +4311,17 @@
3188 4311 } elseif ( ! empty( $content ) && ! empty( $file_type ) && 'wp_block' === $file_type ) {
3189 4312
3190 4313 $editor = ( 'wdkit' === $args['editor'] ) ? 'gutenberg' : $args['editor'];
3191 4314 $blocks = parse_blocks( stripslashes( $content ) );
3192 -
4315 +
3193 4316 $blocks = $this->wdkit_media_import( $blocks, $editor );
3194 4317
3195 4318 $processor = new WDKIT_Nexter_Block_Processor();
3196 4319 $blocks = $processor->run( $blocks );
3197 4320 $content = serialize_blocks( $blocks );
3198 -
4321 +
3199 4322 $content = $this->replace_unicode_glitch( serialize_blocks( $blocks ) );
3200 4323
3201 - if ( ! empty( $category_list ) && is_array( $category_list ) ) {
3202 - $category_ids = array_map( 'intval', $category_list );
3203 - wp_set_post_terms( $inserted_id, $category_ids, 'category' );
3204 - }
3205 -
3206 - if ( ! empty( $tag_list ) && is_array( $tag_list ) ) {
3207 - $tag_ids = array_map( 'intval', $tag_list );
3208 - wp_set_post_terms( $inserted_id, $tag_ids, 'post_tag' );
3209 - }
3210 -
3211 4324 $inserted_post = wp_insert_post(
3212 4325 array(
3213 4326 'post_status' => 'publish',
3214 4327 'post_type' => $post_type,
@@ -3227,9 +4340,9 @@
3227 4340 );
3228 4341 wp_die();
3229 4342 }
3230 4343
3231 - if ( ! empty( $thumb_image ) ) {
4344 + if ( ! empty( $thumb_image ) && wdesignkit_validate_external_url( $thumb_image ) ) {
3232 4345 // $featured_image_url = esc_url_raw( $thumb_image );
3233 4346 $tmp = download_url( $thumb_image );
3234 4347 if ( is_wp_error( $tmp ) ) {
3235 4348 error_log( 'Image download failed: ' . esc_html( $tmp->get_error_message() ) );
@@ -3265,9 +4378,9 @@
3265 4378 $custom_meta = isset( $post_content->custom_meta ) ? json_decode( wp_json_encode( $post_content->custom_meta ), true ) : '';
3266 4379 if ( ! empty( $custom_meta ) ) {
3267 4380 foreach ( $custom_meta as $meta_key => $meta_val ) {
3268 4381 if ( isset( $meta_val[0] ) && ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
3269 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
4382 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
3270 4383 }
3271 4384
3272 4385 if ( '' === get_post_meta( $inserted_post, $meta_key, true ) && isset( $meta_val[0] ) ) {
3273 4386 add_post_meta( $inserted_post, $meta_key, $meta_val[0] );
@@ -3299,13 +4412,20 @@
3299 4412 Tpgb_Library()->remove_backend_dir_files();
3300 4413 }
3301 4414
3302 4415 clean_post_cache( $inserted_post );
4416 +
4417 + // This whole method imports exactly one section per call — unlike
4418 + // wdkit_import_template()/wdkit_import_kit_template(), it never fired this hook
4419 + // at all, so single-section imports (Header/Footer/CTA/etc., a primary import
4420 + // path per the Template Type sidebar) were invisible to tracking entirely.
4421 + do_action( 'wdkit_template_imported', 'single', sanitize_key( $editor ), 1 );
4422 +
3303 4423 wp_send_json(
3304 4424 array(
3305 4425 $temp_id => $temp_detail,
3306 4426 'description' => 'Yay! Your Section has been Successfully Imported.',
3307 - 'message' => 'Successfully Imported.',
4427 + 'message' => __( 'Successfully Imported.', 'wdesignkit' ),
3308 4428 'inserted_id' => $inserted_post,
3309 4429 'success' => true,
3310 4430 )
3311 4431 );
@@ -3316,9 +4436,9 @@
3316 4436 if ( empty( $content ) ) {
3317 4437 wp_send_json(
3318 4438 array(
3319 4439 'template_id' => $template_id,
3320 - 'message' => 'Content is Empty.',
4440 + 'message' => __( 'Content is Empty.', 'wdesignkit' ),
3321 4441 )
3322 4442 );
3323 4443 wp_die();
3324 4444 } elseif ( ! empty( $content ) && ! empty( $file_type ) && 'elementor' === $file_type ) {
@@ -3356,9 +4476,9 @@
3356 4476 }
3357 4477
3358 4478 $inserted_id = $new_document->get_main_id();
3359 4479
3360 - if ( ! empty( $thumb_image ) ) {
4480 + if ( ! empty( $thumb_image ) && wdesignkit_validate_external_url( $thumb_image ) ) {
3361 4481 // $featured_image_url = esc_url_raw( $thumb_image );
3362 4482 $tmp = download_url( $thumb_image );
3363 4483 if ( is_wp_error( $tmp ) ) {
3364 4484 error_log( 'Image download failed: ' . esc_html( $tmp->get_error_message() ) );
@@ -3412,9 +4532,9 @@
3412 4532 $custom_meta = isset( $post_content->custom_meta ) ? json_decode( wp_json_encode( $post_content->custom_meta ), true ) : '';
3413 4533 if ( ! empty( $custom_meta ) ) {
3414 4534 foreach ( $custom_meta as $meta_key => $meta_val ) {
3415 4535 if ( ! empty( $meta_val[0] ) && is_serialized( $meta_val[0] ) ) {
3416 - $meta_val[0] = maybe_unserialize( $meta_val[0] );
4536 + $meta_val[0] = unserialize( $meta_val[0], array( 'allowed_classes' => false ) );
3417 4537 }
3418 4538 if ( '' === get_post_meta( $inserted_id, $meta_key, true ) ) {
3419 4539 add_post_meta( $inserted_id, $meta_key, $meta_val[0] );
3420 4540 }
@@ -3438,14 +4558,18 @@
3438 4558 }
3439 4559
3440 4560 \Elementor\Plugin::$instance->files_manager->clear_cache();
3441 4561
4562 + // See the matching note in the Gutenberg branch above — this method never
4563 + // fired the tracking hook for either editor.
4564 + do_action( 'wdkit_template_imported', 'single', 'elementor', 1 );
4565 +
3442 4566 wp_send_json(
3443 4567 array(
3444 4568 $temp_id => $temp_detail,
3445 4569 'content' => $temp_con,
3446 4570 'description' => 'Yay! Your Section has been Successfully Imported.',
3447 - 'message' => 'Successfully Imported.',
4571 + 'message' => __( 'Successfully Imported.', 'wdesignkit' ),
3448 4572 'inserted_id' => $inserted_id,
3449 4573 'success' => true,
3450 4574 )
3451 4575 );
@@ -3575,8 +4699,12 @@
3575 4699 }
3576 4700
3577 4701 $image_url = esc_url_raw( $_POST['image_url'] );
3578 4702
4703 + if ( ! wdesignkit_validate_external_url( $image_url ) ) {
4704 + wp_send_json_error( 'Image could not be downloaded.' );
4705 + }
4706 +
3579 4707 $tmp_file = download_url( $image_url );
3580 4708 if ( is_wp_error( $tmp_file ) ) {
3581 4709 wp_send_json_error( 'Image could not be downloaded.' );
3582 4710 }
@@ -3612,9 +4740,21 @@
3612 4740
3613 4741 $upload_dir = wp_upload_dir();
3614 4742 $result_urls = array();
3615 4743
4744 + $colour_index = 0;
3616 4745 foreach ( $img_colors as $name => $rgb ) {
4746 + // $name is a key from the posted colours payload and went straight into the output
4747 + // filename, so traversal sequences in it steered imagepng() outside the upload
4748 + // directory (CWE-22, ClickUp 86d41ced6). sanitize_file_name() flattens it to one
4749 + // path segment; a key made only of dots/separators sanitizes to empty, so fall back
4750 + // to a positional index rather than writing to a bare "colored--<time>.png".
4751 + ++$colour_index;
4752 + $safe_name = sanitize_file_name( (string) $name );
4753 + if ( '' === $safe_name ) {
4754 + $safe_name = 'colour-' . $colour_index;
4755 + }
4756 +
3617 4757 $new = imagecreatetruecolor( $width, $height );
3618 4758 imagesavealpha( $new, true );
3619 4759 imagealphablending( $new, false );
3620 4760
@@ -3640,9 +4780,9 @@
3640 4780 imagesetpixel( $new, $x, $y, $color );
3641 4781 }
3642 4782 }
3643 4783
3644 - $filename = 'colored-' . $name . '-' . time() . '.png';
4784 + $filename = 'colored-' . $safe_name . '-' . time() . '.png';
3645 4785 $filepath = $upload_dir['path'] . '/' . $filename;
3646 4786
3647 4787 imagepng( $new, $filepath );
3648 4788 imagedestroy( $new );
@@ -3858,8 +4998,13 @@
3858 4998 $page_information = isset( $_POST['page_information'] ) ? sanitize_text_field( wp_unslash( $_POST['page_information'] ) ) : '';
3859 4999 $page_information = json_decode( $page_information, true );
3860 5000
3861 5001 if ( ! empty( $page_information ) && is_array( $page_information ) ) {
5002 +
5003 + // Every page and attachment now exists, so resolve any image ID the per-page
5004 + // pass could not (siblings import concurrently and share icons).
5005 + $this->wdkit_sweep_attachment_ids( wp_list_pluck( $page_information, 'inserted_id' ) );
5006 +
3862 5007 // Step 1: banavo mapping [ old_id => new_id ]
3863 5008 $id_mapping = array();
3864 5009 foreach ( $page_information as $page_info ) {
3865 5010 if ( ! empty( $page_info['old_page_id'] ) ) {
@@ -3952,12 +5097,73 @@
3952 5097 }
3953 5098
3954 5099 $response = json_decode( wp_json_encode( $response['data'] ), true );
3955 5100
5101 + $this->wdkit_cache_cloud_usage( $response );
5102 +
3956 5103 wp_send_json( $response );
3957 5104 wp_die();
3958 5105 }
3959 5106
5107 + /**
5108 + * Caches the storage / credit figures this response carried.
5109 + *
5110 + * This handler is the ONLY place those numbers ever exist on the site: the cloud endpoint
5111 + * authenticates with a user token that only a logged-in dashboard request carries, so the
5112 + * analytics heartbeat — which runs on cron with no user at all — can never fetch them itself.
5113 + * Caching them here is what lets Posimyth_Tracker_WDK report them, and it reports the cache's
5114 + * age alongside so a stale reading is recognisable as one.
5115 + *
5116 + * Field names are probed rather than assumed: the cloud has renamed these before, and the
5117 + * licence ability already carries six spellings of its own key field for the same reason. An
5118 + * unrecognised shape simply caches nothing rather than storing a wrong number.
5119 + *
5120 + * Only the figures are kept. No token, no account id, no email — the analytics consent copy
5121 + * promises non-sensitive data only, and this is read by the payload builder.
5122 + *
5123 + * @since 2.6.4
5124 + *
5125 + * @param mixed $data Decoded `data` object from the credits endpoint.
5126 + * @return void
5127 + */
5128 + private function wdkit_cache_cloud_usage( $data ) {
5129 + if ( ! is_array( $data ) ) {
5130 + return;
5131 + }
5132 +
5133 + $pick = static function ( $source, array $fields ) {
5134 + foreach ( $fields as $field ) {
5135 + if ( isset( $source[ $field ] ) && is_numeric( $source[ $field ] ) ) {
5136 + return (float) $source[ $field ];
5137 + }
5138 + }
5139 + return null;
5140 + };
5141 +
5142 + $usage = array(
5143 + 'storage_used' => $pick( $data, array( 'used_storage', 'storage_used', 'used_space' ) ),
5144 + 'storage_total' => $pick( $data, array( 'total_storage', 'storage_total', 'storage', 'total_space' ) ),
5145 + 'credit_used' => $pick( $data, array( 'used_credit', 'credit_used', 'used_credits' ) ),
5146 + 'credit_total' => $pick( $data, array( 'total_credit', 'credit_total', 'credits', 'real_credit' ) ),
5147 + );
5148 +
5149 + $usage = array_filter(
5150 + $usage,
5151 + static function ( $value ) {
5152 + return null !== $value;
5153 + }
5154 + );
5155 +
5156 + if ( empty( $usage ) ) {
5157 + return;
5158 + }
5159 +
5160 + $usage['cached_at'] = gmdate( 'Y-m-d H:i:s' );
5161 +
5162 + // Not autoloaded: read once a week by the heartbeat, never on a front-end request.
5163 + update_option( 'wdkit_cloud_usage', $usage, false );
5164 + }
5165 +
3960 5166 public function wdkit_nxt_thembuilder_reset() {
3961 5167 $post_id = isset( $_POST['post_id'] ) ? sanitize_text_field( $_POST['post_id'] ) : '';
3962 5168 $sections_layout = get_post_meta( $post_id, 'nxt-hooks-layout-sections', true );
3963 5169
@@ -4290,10 +5496,35 @@
4290 5496 'description' => esc_html__( 'widget JSON file not found.', 'wdesignkit' ),
4291 5497 );
4292 5498 }
4293 5499
4294 - $json_path = WDKIT_BUILDER_PATH . "/{$widget_type}/{$folder_name}/{$file_name}";
5500 + // Read-side twin of the write and delete traversals fixed in 86d41cckh / 86d41ccz2: all
5501 + // three segments arrive from $_POST with only wp_unslash() applied — which strips
5502 + // nothing path-relevant — so "../" in any of them walked out of the builder directory
5503 + // and this handler returned the decoded contents of any .json file the web server user
5504 + // could read (CWE-22, ClickUp 86d41zaun).
5505 + $safe_path = wdesignkit_widget_path_guard( $widget_type, $folder_name, $file_name );
4295 5506
5507 + if ( false === $safe_path || '' === $safe_path['folder'] || '' === $safe_path['file'] ) {
5508 + return array(
5509 + 'success' => false,
5510 + 'message' => esc_html__( 'Widget JSON not found', 'wdesignkit' ),
5511 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
5512 + );
5513 + }
5514 +
5515 + $json_path = $safe_path['base'];
5516 +
5517 + // Re-check the resolved file: the component guard above cannot see a symlink. Returns
5518 + // false for a path that does not exist, which is the same answer we want anyway.
5519 + if ( ! wdesignkit_path_inside_builder_dir( "$json_path.json" ) ) {
5520 + return array(
5521 + 'success' => false,
5522 + 'message' => esc_html__( 'Widget JSON not found', 'wdesignkit' ),
5523 + 'description' => esc_html__( 'widget JSON file not found.', 'wdesignkit' ),
5524 + );
5525 + }
5526 +
4296 5527 $json_data = wp_json_file_decode( "$json_path.json" );
4297 5528 if ( ! empty( $json_data ) ) {
4298 5529 $result = (object) array(
4299 5530 'success' => true,
@@ -4319,9 +5550,9 @@
4319 5550 *
4320 5551 * @since 1.0.0
4321 5552 */
4322 5553 protected function wdkit_download_widget() {
4323 - $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_sanitizer_bypass( $_POST, 'widget_info', 'none' ) : '';
5554 + $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_extract_post_field( $_POST, 'widget_info', 'none' ) : '';
4324 5555 $data = json_decode( stripslashes( $data ) );
4325 5556
4326 5557 $array_data = array(
4327 5558 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
@@ -4389,14 +5620,34 @@
4389 5620 if ( ! is_array( $json_data ) ) {
4390 5621 $json_data = json_decode( $json_data, true );
4391 5622 }
4392 5623
4393 - $title = ! empty( $json_data['widget_data']['widgetdata']['name'] ) ? sanitize_text_field( $json_data['widget_data']['widgetdata']['name'] ) : '';
4394 - $builder = ! empty( $json_data['widget_data']['widgetdata']['type'] ) ? sanitize_text_field( $json_data['widget_data']['widgetdata']['type'] ) : '';
4395 - $w_uniq = ! empty( $json_data['widget_data']['widgetdata']['widget_id'] ) ? sanitize_text_field( $json_data['widget_data']['widgetdata']['widget_id'] ) : '';
5624 + // Sanitize as filenames before use in the widget path (CWE-22): sanitize_file_name()
5625 + // on name/id and sanitize_key() + allowlist on the builder strip path separators and
5626 + // dots so a crafted cloud response cannot escape WDKIT_BUILDER_PATH.
5627 + $title = ! empty( $json_data['widget_data']['widgetdata']['name'] ) ? sanitize_file_name( $json_data['widget_data']['widgetdata']['name'] ) : '';
5628 + $builder = ! empty( $json_data['widget_data']['widgetdata']['type'] ) ? sanitize_key( $json_data['widget_data']['widgetdata']['type'] ) : '';
5629 + $w_uniq = ! empty( $json_data['widget_data']['widgetdata']['widget_id'] ) ? sanitize_file_name( $json_data['widget_data']['widgetdata']['widget_id'] ) : '';
4396 5630
4397 - $folder_name = str_replace( ' ', '-', $title ) . '_' . $w_uniq;
4398 - $file_name = str_replace( ' ', '_', $title ) . '_' . $w_uniq;
5631 + $allowed_builders = array( 'elementor', 'gutenberg', 'gutenberg_core', 'bricks' );
5632 + if ( '' === $title || '' === $w_uniq || ! in_array( $builder, $allowed_builders, true ) ) {
5633 + $responce = (object) array(
5634 + 'success' => false,
5635 + 'message' => esc_html__( 'Operation Failed!', 'wdesignkit' ),
5636 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
5637 + );
5638 +
5639 + wp_send_json( $responce );
5640 + wp_die();
5641 + }
5642 +
5643 + // Canonical helpers replace spaces BEFORE sanitize_file_name(). $title above is
5644 + // already sanitized, which collapsed spaces to hyphens and left the underscore pass
5645 + // with nothing to do — a multi-word title wrote "My-Widget_id.json" next to the
5646 + // "My_Widget_id.php" the builder's save path writes. The loader pairs the two by
5647 + // swapping .php for .json, so the widget was silently dropped (ClickUp 86d41cck5).
5648 + $folder_name = wdesignkit_widget_folder_name( $title, $w_uniq );
5649 + $file_name = wdesignkit_widget_file_name( $title, $w_uniq );
4399 5650 $builder_type_path = WDKIT_BUILDER_PATH . "/{$builder}/";
4400 5651
4401 5652 if ( ! is_dir( $builder_type_path ) ) {
4402 5653 wp_mkdir_p( $builder_type_path );
@@ -4406,15 +5657,27 @@
4406 5657 wp_mkdir_p( $builder_type_path . $folder_name );
4407 5658 }
4408 5659
4409 5660 if ( ! empty( $img_url ) ) {
4410 - $img_body = wp_remote_get( $img_url );
4411 - $img_ext = pathinfo( $img_url )['extension'];
5661 + // SSRF guard (CWE-918): validate the resolved host before fetching.
5662 + $img_body = wdesignkit_safe_remote_get( $img_url );
5663 + if ( ! is_wp_error( $img_body ) ) {
5664 + // The remote extension was written verbatim here, so a cloud response naming a
5665 + // ".php" image put executable PHP in the builder directory (CWE-434,
5666 + // ClickUp 86d41cczd). An empty return means the bytes are not an image.
5667 + $img_ext = wdesignkit_safe_image_extension( $img_url, $img_body['body'] );
4412 5668
4413 - $wp_filesystem->put_contents( WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name.$img_ext", $img_body['body'] );
4414 - $json_data['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
5669 + if ( '' !== $img_ext ) {
5670 + $wp_filesystem->put_contents( WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name.$img_ext", $img_body['body'] );
5671 + $json_data['widget_data']['widgetdata']['w_image'] = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
5672 + }
5673 + }
4415 5674 }
4416 5675
5676 + if ( function_exists( 'wdesignkit_invalidate_widget_registry' ) ) {
5677 + wdesignkit_invalidate_widget_registry( $builder );
5678 + }
5679 +
4417 5680 // Bug E fix (part 2): success was hardcoded false on the successful download path — always reported failure.
4418 5681 $result = (object) array(
4419 5682 'success' => true,
4420 5683 'message' => ! empty( $response['message'] ) ? $response['message'] : esc_html__( 'no message', 'wdesignkit' ),
@@ -4432,9 +5695,9 @@
4432 5695 *
4433 5696 * @since 1.0.0
4434 5697 */
4435 5698 protected function wdkit_add_widget() {
4436 - $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_sanitizer_bypass( $_POST, 'widget_info', 'none' ) : '';
5699 + $data = ! empty( $_POST['widget_info'] ) ? $this->wdkit_extract_post_field( $_POST, 'widget_info', 'none' ) : '';
4437 5700 $data = base64_decode( $data );
4438 5701 $data = json_decode( $data );
4439 5702
4440 5703 $title = isset( $data->title ) ? sanitize_text_field( $data->title ) : '';
@@ -4443,9 +5706,11 @@
4443 5706 $w_image = isset( $data->w_image ) ? esc_url_raw( $data->w_image ) : '';
4444 5707
4445 5708 if ( ! empty( $w_image ) ) {
4446 5709 $w_image = str_replace( '\\', '', $w_image );
4447 - $w_image = wp_remote_get( $w_image )['body'];
5710 + // SSRF guard (CWE-918): validate the resolved host before fetching.
5711 + $fetched = wdesignkit_safe_remote_get( $w_image );
5712 + $w_image = is_wp_error( $fetched ) ? '' : wp_remote_retrieve_body( $fetched );
4448 5713 }
4449 5714
4450 5715 $array_data = array(
4451 5716 'token' => isset( $data->token ) ? sanitize_text_field( $data->token ) : '',
@@ -4485,22 +5750,51 @@
4485 5750 $img_url = ! empty( $response['data']['imgurl'] ) ? $response['data']['imgurl'] : '';
4486 5751
4487 5752 if ( ! empty( $img_url ) && 'error' !== $res ) {
4488 5753
4489 - $img_body = wp_remote_get( $img_url );
4490 - $img_ext = pathinfo( $img_url )['extension'];
4491 - include_once ABSPATH . 'wp-admin/includes/file.php';
4492 - \WP_Filesystem();
4493 - global $wp_filesystem;
4494 - $folder_name = str_replace( ' ', '-', $title ) . '_' . $w_uniq;
4495 - $file_name = str_replace( ' ', '_', $title ) . '_' . $w_uniq;
4496 - $file_path = WDKIT_BUILDER_PATH . "/$builder/$folder_name/$file_name";
5754 + // SSRF guard (CWE-918): validate the resolved host before fetching.
5755 + $img_body = wdesignkit_safe_remote_get( $img_url );
5756 + if ( ! is_wp_error( $img_body ) ) {
5757 + // Verified against the payload rather than trusted from the URL (CWE-434,
5758 + // ClickUp 86d41cczd); '' means the bytes are not an image we accept.
5759 + $img_ext = wdesignkit_safe_image_extension( $img_url, $img_body['body'] );
5760 + include_once ABSPATH . 'wp-admin/includes/file.php';
5761 + \WP_Filesystem();
5762 + global $wp_filesystem;
5763 + // Canonical helpers, so the JSON read and the image write here address the same
5764 + // base name every other writer uses (ClickUp 86d41cck5). They also apply
5765 + // sanitize_file_name(), which $title and $w_uniq had not been through.
5766 + $folder_name = wdesignkit_widget_folder_name( $title, $w_uniq );
5767 + $file_name = wdesignkit_widget_file_name( $title, $w_uniq );
4497 5768
4498 - $u_r_l = wp_json_file_decode( "$file_path.json" );
4499 - $u_r_l->widget_data->widgetdata->w_image = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
5769 + // $builder reaches here with only sanitize_text_field() applied and no
5770 + // allowlist, so it was a live traversal segment in this path (CWE-22,
5771 + // ClickUp 86d41cckh). Unlike the download handler earlier in this file, this
5772 + // one had neither the builder allowlist nor a containment check.
5773 + $safe_path = wdesignkit_widget_path_guard( $builder, $folder_name, $file_name );
5774 + if ( false === $safe_path || ! wdesignkit_path_inside_builder_dir( $safe_path['dir'] ) ) {
5775 + wp_send_json(
5776 + (object) array(
5777 + 'success' => false,
5778 + 'message' => esc_html__( 'Operation Failed!', 'wdesignkit' ),
5779 + 'description' => esc_html__( 'Invalid widget path.', 'wdesignkit' ),
5780 + )
5781 + );
5782 + wp_die();
5783 + }
4500 5784
4501 - $wp_filesystem->put_contents( "$file_path.json", wp_json_encode( $u_r_l ) );
4502 - $wp_filesystem->put_contents( "$file_path.$img_ext", $img_body['body'] );
5785 + $builder = $safe_path['builder'];
5786 + $file_path = $safe_path['base'];
5787 +
5788 + $u_r_l = wp_json_file_decode( "$file_path.json" );
5789 +
5790 + if ( '' !== $img_ext ) {
5791 + $u_r_l->widget_data->widgetdata->w_image = WDKIT_SERVER_PATH . "/$builder/$folder_name/$file_name.$img_ext";
5792 + $wp_filesystem->put_contents( "$file_path.$img_ext", $img_body['body'] );
5793 + }
5794 +
5795 + $wp_filesystem->put_contents( "$file_path.json", wp_json_encode( $u_r_l ) );
5796 + }
4503 5797 }
4504 5798
4505 5799 wp_send_json( $response );
4506 5800 wp_die();
@@ -4600,9 +5894,9 @@
4600 5894 'template' => isset( $get_setting['template'] ) ? $get_setting['template'] : true,
4601 5895 'gutenberg_builder' => isset( $get_setting['gutenberg_builder'] ) ? $get_setting['gutenberg_builder'] : true,
4602 5896 'gutenberg_core_builder' => isset( $get_setting['gutenberg_core_builder'] ) ? $get_setting['gutenberg_core_builder'] : false,
4603 5897 'elementor_builder' => isset( $get_setting['elementor_builder'] ) ? $get_setting['elementor_builder'] : true,
4604 - 'bricks_builder' => isset( $get_setting['bricks_builder'] ) ? $get_setting['bricks_builder'] : false,
5898 + 'bricks_builder' => isset( $get_setting['bricks_builder'] ) ? $get_setting['bricks_builder'] : true,
4605 5899 'gutenberg_template' => isset( $get_setting['gutenberg_template'] ) ? $get_setting['gutenberg_template'] : true,
4606 5900 'elementor_template' => isset( $get_setting['elementor_template'] ) ? $get_setting['elementor_template'] : true,
4607 5901 'code_snippet' => isset( $get_setting['code_snippet'] ) ? $get_setting['code_snippet'] : true,
4608 5902 'cross_copy_paste' => isset( $get_setting['cross_copy_paste'] ) ? $get_setting['cross_copy_paste'] : false,
@@ -4663,9 +5957,9 @@
4663 5957 }
4664 5958
4665 5959 $get_updated_data = get_option( 'wkit_white_label', false );
4666 5960 $response = array(
4667 - 'message' => 'Data Added successfully',
5961 + 'message' => __( 'Data Added successfully', 'wdesignkit' ),
4668 5962 'success' => true,
4669 5963 'data' => $get_updated_data,
4670 5964 );
4671 5965
@@ -4712,21 +6006,21 @@
4712 6006 if ( ! empty( $response['data'] ) ) {
4713 6007 $response = json_decode( wp_json_encode( $response['data'] ), true );
4714 6008
4715 6009 if ( ! empty( $response['data']['tpae_licence'] ) && is_serialized( $response['data']['tpae_licence'] ) ) {
4716 - $response['data']['tpae_licence'] = unserialize( $response['data']['tpae_licence'] );
6010 + $response['data']['tpae_licence'] = unserialize( $response['data']['tpae_licence'], array( 'allowed_classes' => false ) );
4717 6011 }
4718 6012
4719 6013 if ( ! empty( $response['data']['tpag_licence'] ) && is_serialized( $response['data']['tpag_licence'] ) ) {
4720 - $response['data']['tpag_licence'] = unserialize( $response['data']['tpag_licence'] );
6014 + $response['data']['tpag_licence'] = unserialize( $response['data']['tpag_licence'], array( 'allowed_classes' => false ) );
4721 6015 }
4722 6016
4723 6017 if ( ! empty( $response['data']['uichemy_licence'] ) && is_serialized( $response['data']['uichemy_licence'] ) ) {
4724 - $response['data']['uichemy_licence'] = unserialize( $response['data']['uichemy_licence'] );
6018 + $response['data']['uichemy_licence'] = unserialize( $response['data']['uichemy_licence'], array( 'allowed_classes' => false ) );
4725 6019 }
4726 6020
4727 6021 if ( ! empty( $response['data']['wdkit_licence'] ) && is_serialized( $response['data']['wdkit_licence'] ) ) {
4728 - $response['data']['wdkit_licence'] = unserialize( $response['data']['wdkit_licence'] );
6022 + $response['data']['wdkit_licence'] = unserialize( $response['data']['wdkit_licence'], array( 'allowed_classes' => false ) );
4729 6023
4730 6024 // Store WDesignKit license status locally for quick access
4731 6025 if ( ! empty( $response['data']['wdkit_licence'] ) && is_array( $response['data']['wdkit_licence'] ) ) {
4732 6026 update_option( 'wdkit_licence_data', $response['data']['wdkit_licence'] );
@@ -4733,9 +6027,9 @@
4733 6027 }
4734 6028 }
4735 6029
4736 6030 if ( ! empty( $response['data']['wdkit_licence_extra'] ) && is_serialized( $response['data']['wdkit_licence_extra'] ) ) {
4737 - $response['data']['wdkit_licence_extra'] = unserialize( $response['data']['wdkit_licence_extra'] );
6031 + $response['data']['wdkit_licence_extra'] = unserialize( $response['data']['wdkit_licence_extra'], array( 'allowed_classes' => false ) );
4738 6032 }
4739 6033 }
4740 6034
4741 6035 wp_send_json( $response );
@@ -4778,12 +6072,16 @@
4778 6072 */
4779 6073 protected function wdkit_sync_licence_key() {
4780 6074 $token = ! empty( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : '';
4781 6075 $licencename = ! empty( $_POST['licencename'] ) ? sanitize_text_field( wp_unslash( $_POST['licencename'] ) ) : '';
6076 + // Needed to identify which extra-credit key to sync (wdkit_extra / wdkit_ai_extra
6077 + // are arrays matched by the api key's last digits on the server).
6078 + $apikey = ! empty( $_POST['apikey'] ) ? sanitize_text_field( wp_unslash( $_POST['apikey'] ) ) : '';
4782 6079
4783 6080 $args = array(
4784 6081 'token' => $token,
4785 6082 'licencename' => $licencename,
6083 + 'apikey' => $apikey,
4786 6084 );
4787 6085
4788 6086 $response = $this->wkit_api_call( $args, 'licence_sync' );
4789 6087
@@ -4916,9 +6214,9 @@
4916 6214 $token = $this->wdkit_login_user_token( $email );
4917 6215 $args = array( 'token' => $token );
4918 6216
4919 6217 if ( 'session' !== $logout_type ) {
4920 - delete_transient( 'wdkit_auth_' . $email );
6218 + delete_transient( 'wdkit_auth_' . wdesignkit_cloud_session_key( $email ) );
4921 6219 // Clear stored license data on logout so banner shows again
4922 6220 delete_option( 'wdkit_licence_data' );
4923 6221 $response = WDesignKit_Data_Query::get_data( 'logout', $args );
4924 6222 }
@@ -4938,9 +6236,9 @@
4938 6236 */
4939 6237 protected function wdkit_login_user_token( $email = '' ) {
4940 6238
4941 6239 if ( ! empty( $email ) ) {
4942 - $user_key = strstr( $email, '@', true );
6240 + $user_key = wdesignkit_cloud_session_key( $email );
4943 6241 $get_login = get_transient( 'wdkit_auth_' . $user_key );
4944 6242
4945 6243 if ( ! empty( $get_login ) && ! empty( $get_login['token'] ) ) {
4946 6244 return $get_login['token'];
@@ -4958,9 +6256,9 @@
4958 6256 * @param string $data send all post data.
4959 6257 * @param string $type store text data.
4960 6258 * @param string $condition store text data.
4961 6259 */
4962 - protected function wdkit_sanitizer_bypass( $data, $type, $condition = 'none' ) {
6260 + protected function wdkit_extract_post_field( $data, $type, $condition = 'none' ) {
4963 6261
4964 6262 if ( 'none' === $condition ) {
4965 6263 return $data[ $type ];
4966 6264 } elseif ( 'cr_widget' === $condition ) {
@@ -4965,8 +6263,10 @@
4965 6263 return $data[ $type ];
4966 6264 } elseif ( 'cr_widget' === $condition ) {
4967 6265 return $data[ $type ];
4968 6266 }
6267 +
6268 + return null;
4969 6269 }
4970 6270
4971 6271
4972 6272 /**