PluginProbe
WDesignKit – AI Templates, Widget Builder & MCP Workflow / 2.7.0
WDesignKit – AI Templates, Widget Builder & MCP Workflow v2.7.0
2.7.1 2.7.0 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 All 130 releases
wdesignkit / includes / admin / hooks / class-wdkit-copy-paste-ajax.php

class-wdkit-copy-paste-ajax.php in WDesignKit – AI Templates, Widget Builder & MCP Workflow 2.7.0, at includes/admin/hooks/class-wdkit-copy-paste-ajax.php

488 lines 14.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Cross-Domain Copy Paste AJAX Handler for WDesignKit
4 *
5 * Handles server-side logic for the cross-domain copy/paste feature.
6 * Supports Elementor, Gutenberg, and Bricks builders.
7 *
8 * @link https://wdesignkit.com/
9 * @since 2.3.4
10 *
11 * @package wdesignkit
12 */
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 if ( ! class_exists( 'Wdkit_Copy_Paste_Ajax' ) ) {
19
20 /**
21 * Wdkit_Copy_Paste_Ajax
22 *
23 * @since 2.3.4
24 */
25 class Wdkit_Copy_Paste_Ajax {
26
27 /**
28 * Member Variable
29 *
30 * @var instance
31 */
32 private static $instance;
33
34 /**
35 * Initiator
36 */
37 public static function get_instance() {
38 if ( ! isset( self::$instance ) ) {
39 self::$instance = new self();
40 }
41 return self::$instance;
42 }
43
44 /**
45 * Constructor - register AJAX hooks
46 */
47 public function __construct() {
48 // Elementor media import (cross-domain image re-upload)
49 add_action( 'wp_ajax_wdkit_cp_media_import', array( $this, 'wdkit_cp_media_import' ) );
50
51 // Enable widgets/blocks on paste
52 add_action( 'wp_ajax_wdkit_cp_live_paste', array( $this, 'wdkit_cp_live_paste' ) );
53
54 // Check which WDesignKit widget IDs are installed locally.
55 add_action( 'wp_ajax_wdkit_cp_check_widgets', array( $this, 'wdkit_cp_check_widgets' ) );
56 }
57
58 /**
59 * Check which WDesignKit custom widget IDs are installed locally for
60 * a given builder. Returns the missing list along with a friendly
61 * name (pulled from the widget's stored JSON when available) so the
62 * editor popup can show meaningful labels while it triggers
63 * downloads.
64 *
65 * Request:
66 * POST kit_nonce
67 * POST builder = 'elementor' | 'gutenberg'
68 * POST widget_ids = JSON array of widget IDs *without* the wb- prefix
69 *
70 * Response:
71 * { success: true, data: { installed: [...], missing: [{w_unique, name}] } }
72 *
73 * @since 2.3.5
74 */
75 public function wdkit_cp_check_widgets() {
76
77 // Verify nonce.
78 if ( ! check_ajax_referer( 'wdkit_nonce', 'kit_nonce', false ) ) {
79 wp_send_json_error( array( 'message' => __( 'Security check failed.', 'wdesignkit' ) ), 403 );
80 return;
81 }
82
83 // Capability check — only users who can edit posts (i.e. anyone
84 // who can reach the Elementor/Gutenberg editor) should hit this.
85 // The endpoint only reads the local widget folder, no mutation.
86 if ( ! current_user_can( 'edit_posts' ) ) {
87 wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'wdesignkit' ) ), 403 );
88 return;
89 }
90
91 $builder = isset( $_POST['builder'] ) ? sanitize_key( wp_unslash( $_POST['builder'] ) ) : '';
92 $raw_ids = isset( $_POST['widget_ids'] ) ? wp_unslash( $_POST['widget_ids'] ) : '[]';
93
94 // Whitelist builders — anything else gets rejected outright.
95 if ( ! in_array( $builder, array( 'elementor', 'gutenberg' ), true ) ) {
96 wp_send_json_error( array( 'message' => __( 'Invalid builder.', 'wdesignkit' ) ) );
97 return;
98 }
99
100 // Defensive size cap — the editor sends a small list; refuse
101 // anything that looks like an abuse attempt before we even
102 // json_decode it.
103 if ( ! is_string( $raw_ids ) || strlen( $raw_ids ) > 32768 ) {
104 wp_send_json_error( array( 'message' => __( 'Payload too large.', 'wdesignkit' ) ) );
105 return;
106 }
107
108 $widget_ids = json_decode( $raw_ids, true );
109 if ( ! is_array( $widget_ids ) ) {
110 $widget_ids = array();
111 }
112
113 // Normalise + strict-validate each id:
114 // 1. Strip wb- prefix.
115 // 2. Allow only [A-Za-z0-9_-] characters (widget IDs are
116 // slug-like by construction). Drop anything else.
117 // 3. Drop empties.
118 // 4. Dedupe.
119 // 5. Cap at 200 ids per request.
120 $widget_ids = array_values( array_unique( array_filter( array_map(
121 function ( $id ) {
122 $id = sanitize_text_field( (string) $id );
123 if ( 0 === strpos( $id, 'wb-' ) ) {
124 $id = substr( $id, 3 );
125 }
126 return preg_match( '/^[A-Za-z0-9_-]+$/', $id ) ? $id : '';
127 },
128 $widget_ids
129 ) ) ) );
130
131 if ( count( $widget_ids ) > 200 ) {
132 $widget_ids = array_slice( $widget_ids, 0, 200 );
133 }
134
135 if ( empty( $widget_ids ) ) {
136 wp_send_json_success( array( 'installed' => array(), 'missing' => array() ) );
137 return;
138 }
139
140 // Resolve the builder directory. WDKIT_BUILDER_PATH is a plugin
141 // constant; we still validate that the resolved path stays inside
142 // the constant base (defensive — $builder is whitelisted above so
143 // this is belt-and-braces).
144 if ( ! defined( 'WDKIT_BUILDER_PATH' ) ) {
145 wp_send_json_success( array(
146 'installed' => array(),
147 'missing' => array_map(
148 function ( $id ) { return array( 'w_unique' => $id, 'name' => 'Widget ' . $id ); },
149 $widget_ids
150 ),
151 ) );
152 return;
153 }
154
155 $builder_dir = trailingslashit( WDKIT_BUILDER_PATH ) . $builder;
156 $real_base = realpath( WDKIT_BUILDER_PATH );
157 $real_dir = realpath( $builder_dir );
158
159 // Ensure the resolved builder dir is actually within the plugin's
160 // builder root. Refuse to scan otherwise.
161 if ( ! $real_base || ! $real_dir || 0 !== strpos( $real_dir, $real_base ) ) {
162 wp_send_json_success( array(
163 'installed' => array(),
164 'missing' => array_map(
165 function ( $id ) { return array( 'w_unique' => $id, 'name' => 'Widget ' . $id ); },
166 $widget_ids
167 ),
168 ) );
169 return;
170 }
171
172 // Pre-index installed widgets in a single pass so the lookup
173 // per requested id is O(1) instead of O(folders) — relevant on
174 // sites with many installed widgets.
175 $index = $this->wdkit_cp_index_installed_widgets( $builder_dir );
176
177 $installed = array();
178 $missing = array();
179
180 foreach ( $widget_ids as $w_unique ) {
181 if ( isset( $index[ $w_unique ] ) ) {
182 $installed[] = $w_unique;
183 } else {
184 $missing[] = array(
185 'w_unique' => $w_unique,
186 'name' => 'Widget ' . $w_unique,
187 );
188 }
189 }
190
191 wp_send_json_success( array(
192 'installed' => $installed,
193 'missing' => $missing,
194 ) );
195 }
196
197 /**
198 * Build a lookup map of locally-installed widget IDs for a given
199 * builder. Keyed by widget_id, value is the resolved name when we
200 * can read it from the JSON manifest.
201 *
202 * @since 2.3.5
203 *
204 * @param string $builder_dir Absolute path to the builder folder.
205 * @return array<string,string>
206 */
207 private function wdkit_cp_index_installed_widgets( $builder_dir ) {
208
209 $map = array();
210
211 if ( ! is_dir( $builder_dir ) ) {
212 return $map;
213 }
214
215 $entries = @scandir( $builder_dir );
216 if ( ! is_array( $entries ) ) {
217 return $map;
218 }
219
220 foreach ( $entries as $entry ) {
221 if ( '.' === $entry || '..' === $entry ) {
222 continue;
223 }
224
225 $folder_path = $builder_dir . '/' . $entry;
226 if ( ! is_dir( $folder_path ) ) {
227 continue;
228 }
229
230 $widget_id = '';
231 $widget_name = '';
232
233 // Conventional folder naming is `<name>_<widget_id>` — the
234 // suffix is enough to populate the map fast.
235 $us = strrpos( $entry, '_' );
236 if ( false !== $us ) {
237 $candidate = substr( $entry, $us + 1 );
238 if ( preg_match( '/^[A-Za-z0-9_-]+$/', $candidate ) ) {
239 $widget_id = $candidate;
240 }
241 }
242
243 // Read the JSON manifest when present so we can surface a
244 // friendly widget name to the user. Suppress errors — a
245 // broken/missing manifest is non-fatal here.
246 $json_files = glob( $folder_path . '/*.json' );
247 if ( ! empty( $json_files ) ) {
248 $json = wp_json_file_decode( $json_files[0], array( 'associative' => true ) );
249 if ( is_array( $json ) && isset( $json['widget_data']['widgetdata'] ) ) {
250 $wd = $json['widget_data']['widgetdata'];
251 if ( ! empty( $wd['widget_id'] ) ) {
252 $widget_id = (string) $wd['widget_id'];
253 }
254 if ( ! empty( $wd['name'] ) ) {
255 $widget_name = (string) $wd['name'];
256 }
257 }
258 }
259
260 if ( '' !== $widget_id ) {
261 $map[ $widget_id ] = $widget_name;
262 }
263 }
264
265 return $map;
266 }
267
268 /**
269 * Cross-domain media import.
270 * Re-uploads images from pasted element data into the local media library.
271 *
272 * @since 2.3.4
273 */
274 public function wdkit_cp_media_import() {
275
276 // Verify nonce (do not die on first call so we control the
277 // response shape).
278 if ( ! check_ajax_referer( 'wdkit_nonce', 'kit_nonce', false ) ) {
279 wp_send_json_error( array( 'message' => __( 'Security check failed.', 'wdesignkit' ) ), 403 );
280 return;
281 }
282
283 // Require both edit_posts + upload_files — this endpoint sideloads
284 // remote images into the local media library.
285 if ( ! current_user_can( 'edit_posts' ) || ! current_user_can( 'upload_files' ) ) {
286 wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'wdesignkit' ) ), 403 );
287 return;
288 }
289
290 $copy_content = isset( $_POST['copy_content'] ) ? wp_unslash( $_POST['copy_content'] ) : '';
291
292 if ( empty( $copy_content ) || ! is_string( $copy_content ) ) {
293 wp_send_json_error( array( 'message' => __( 'Empty content.', 'wdesignkit' ) ) );
294 return;
295 }
296
297 // Defensive size cap — copy/paste payloads are bounded by the
298 // editor, but the endpoint is logged-in-admin only so a generous
299 // limit is fine. 5 MiB protects against accidental DoS.
300 if ( strlen( $copy_content ) > 5 * 1024 * 1024 ) {
301 wp_send_json_error( array( 'message' => __( 'Payload too large.', 'wdesignkit' ) ) );
302 return;
303 }
304
305 $decoded = json_decode( $copy_content, true );
306 if ( ! is_array( $decoded ) ) {
307 wp_send_json_error( array( 'message' => __( 'Invalid JSON payload.', 'wdesignkit' ) ) );
308 return;
309 }
310
311 // Only needed for Elementor — uses Elementor's DB + elements manager.
312 if ( ! class_exists( '\Elementor\Plugin' ) ) {
313 wp_send_json_error( array( 'message' => __( 'Elementor not active.', 'wdesignkit' ) ) );
314 return;
315 }
316
317 $data = array( $decoded );
318 $data = $this->wdkit_replace_element_ids( $data );
319 $data = $this->wdkit_import_media( $data );
320
321 wp_send_json_success( $data );
322 }
323
324 /**
325 * Live paste handler — enables required widgets/blocks before pasting.
326 *
327 * @since 2.3.4
328 */
329 public function wdkit_cp_live_paste() {
330
331 if ( ! check_ajax_referer( 'wdkit_nonce', 'kit_nonce', false ) ) {
332 wp_send_json( $this->wdkit_cp_response( false, __( 'Invalid nonce.', 'wdesignkit' ), __( 'Security check failed. Please refresh and try again.', 'wdesignkit' ) ) );
333 return;
334 }
335
336 // `manage_options` is required because this endpoint can mutate
337 // site-wide options (Elementor experiment flags).
338 if ( ! current_user_can( 'manage_options' ) ) {
339 wp_send_json( $this->wdkit_cp_response( false, __( 'Insufficient permissions.', 'wdesignkit' ), __( 'You do not have permission to perform this action.', 'wdesignkit' ) ) );
340 return;
341 }
342
343 $type = isset( $_POST['type'] ) ? strtolower( sanitize_text_field( wp_unslash( $_POST['type'] ) ) ) : '';
344
345 switch ( $type ) {
346 case 'enable_elementor_container':
347 $response = $this->wdkit_enable_elementor_container();
348 break;
349
350 default:
351 $response = array( 'success' => true, 'message' => 'ok' );
352 break;
353 }
354
355 wp_send_json( $response );
356 }
357
358 /**
359 * Enable Elementor flexbox container experiment if not already active.
360 *
361 * @since 2.3.4
362 * @return array
363 */
364 private function wdkit_enable_elementor_container() {
365
366 $option_value = get_option( 'elementor_experiment-container', false );
367
368 // `update_option` handles both create and update cases — no need
369 // to branch on `false`.
370 if ( 'active' !== $option_value ) {
371 update_option( 'elementor_experiment-container', 'active' );
372 }
373
374 $widgets_name = isset( $_POST['widgets_name'] ) ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['widgets_name'] ) ) : array();
375
376 return array(
377 'success' => true,
378 'widgets' => $widgets_name,
379 'extensions' => '',
380 );
381 }
382
383 /**
384 * Replace all element IDs with fresh unique IDs to avoid conflicts.
385 *
386 * @since 2.3.4
387 *
388 * @param array $data Element data array.
389 * @return array
390 */
391 private function wdkit_replace_element_ids( $data ) {
392
393 return \Elementor\Plugin::instance()->db->iterate_data(
394 $data,
395 function ( $element ) {
396 $element['id'] = \Elementor\Utils::generate_random_string();
397 return $element;
398 }
399 );
400 }
401
402 /**
403 * Import media (images) from pasted element data into local media library.
404 *
405 * @since 2.3.4
406 *
407 * @param array $data Element data array.
408 * @return array
409 */
410 private function wdkit_import_media( $data ) {
411
412 return \Elementor\Plugin::instance()->db->iterate_data(
413 $data,
414 function ( $element_data ) {
415 $element = \Elementor\Plugin::instance()->elements_manager->create_element_instance( $element_data );
416
417 if ( ! $element ) {
418 return $element_data;
419 }
420
421 return $this->wdkit_run_on_import( $element );
422 }
423 );
424 }
425
426 /**
427 * Run on_import on element and its controls to handle media URLs.
428 *
429 * @since 2.3.4
430 *
431 * @param object $element Elementor element instance.
432 * @return array
433 */
434 private function wdkit_run_on_import( $element ) {
435
436 $element_data = $element->get_data();
437 $on_import = 'on_import';
438
439 if ( method_exists( $element, $on_import ) ) {
440 $element_data = $element->{$on_import}( $element_data );
441 }
442
443 foreach ( $element->get_controls() as $control ) {
444 $control_type = \Elementor\Plugin::instance()->controls_manager->get_control( $control['type'] );
445 $control_name = $control['name'];
446
447 if ( ! $control_type ) {
448 return $element_data;
449 }
450
451 if ( method_exists( $control_type, $on_import ) ) {
452 if ( isset( $element_data['settings'][ $control_name ] ) ) {
453 $element_data['settings'][ $control_name ] = $control_type->{$on_import}(
454 $element->get_settings( $control_name ),
455 $control
456 );
457 }
458 }
459 }
460
461 return $element_data;
462 }
463
464 /**
465 * Build a standard response array.
466 *
467 * @since 2.3.4
468 *
469 * @param bool $success Success flag.
470 * @param string $message Short message.
471 * @param string $description Detailed description.
472 * @return array
473 */
474 private function wdkit_cp_response( $success = false, $message = '', $description = '' ) {
475 // Note: no esc_html() here. JSON encoding via wp_send_json handles
476 // transport-layer escaping, and the client renders these strings
477 // with textContent (never innerHTML), so double-escaping just
478 // produces visible HTML entities on screen.
479 return array(
480 'success' => (bool) $success,
481 'message' => (string) $message,
482 'description' => (string) $description,
483 );
484 }
485 }
486
487 Wdkit_Copy_Paste_Ajax::get_instance();
488 }