PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.2
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.2
3.13.3 3.13.2 3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 All 261 releases
wpfunnels / includes / core / import-export / class-wpfnl-import-funnel.php

class-wpfnl-import-funnel.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.2, at includes/core/import-export/class-wpfnl-import-funnel.php

1,607 lines 67.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPFunnels\Import;
4 use WPFunnels\Wpfnl_functions;
5 use WPFunnels\Wpfnl;
6 use WPFunnels\Data_Store\Wpfnl_Steps_Store_Data;
7 use lementor\TemplateLibrary\Source_Base;
8 use WPFunnelsPro\AbTesting\Wpfnl_Ab_Testing;
9 use Elementor\Plugin;
10 use WPFunnels\Admin\Notices\Notice as Admin_Notice;
11
12 /**
13 * This class is responsible import funnel
14 *
15 * Import json file
16 * @since 1.7.5
17 */
18 class Wpfnl_Import {
19
20 /**
21 * Option holding the structured status of the current/last import run.
22 *
23 * Deliberately an option and not a transient: when a job dies, the last record written is
24 * the only evidence of what happened, and it has to survive so the status endpoint can
25 * report "stalled at funnel 4 of 9" instead of "idle". Always written with autoload off.
26 *
27 * @since 2.5.16
28 */
29 const STATUS_OPTION = 'wpfnl_import_status';
30
31 /**
32 * Highest export schema version this plugin knows how to read.
33 *
34 * @since 2.5.16
35 */
36 const SUPPORTED_SCHEMA_VERSION = 2;
37
38 /**
39 * Seconds without a status write, while the action is still queued, before the run is
40 * reported as stalled. Generous because a single step can legitimately be slow — the
41 * Elementor cache flush is the usual culprit.
42 *
43 * @since 2.5.16
44 */
45 const STALL_TIMEOUT = 120;
46
47 /**
48 * Seconds a run may sit in the `queued` state before we conclude the queue runner is not
49 * firing at all (WP-Cron disabled, no external runner).
50 *
51 * @since 2.5.16
52 */
53 const QUEUE_TIMEOUT = 300;
54
55 /**
56 * Grace period for "Action Scheduler has nothing queued and nobody wrote recently", which
57 * means the worker died mid-run (fatal, OOM kill) rather than finishing.
58 *
59 * @since 2.5.16
60 */
61 const VANISH_GRACE = 15;
62
63 /**
64 * Minimum seconds between step-level status writes. Bounds `update_option()` churn on a
65 * funnel with many steps without making the progress bar look frozen.
66 *
67 * @since 2.5.16
68 */
69 const STATUS_WRITE_INTERVAL = 2;
70
71 /**
72 * Seconds after which a finished run stops being offered as resumable to a fresh page load.
73 *
74 * @since 2.5.16
75 */
76 const STALE_RESULT_AFTER = 600;
77
78 /**
79 * Whether an Elementor step was imported and the file cache still needs flushing.
80 *
81 * @var bool
82 * @since 2.5.15
83 */
84 private $needs_elementor_cache_flush = false;
85
86 /**
87 * In-memory status record for the run in progress, flushed to the option by
88 * `set_import_status()`. Avoids a read-modify-write round trip per update.
89 *
90 * @var array
91 * @since 2.5.16
92 */
93 private $status = [];
94
95 /**
96 * Unix timestamp of the last status flush, used to throttle step-level writes.
97 *
98 * @var int
99 * @since 2.5.16
100 */
101 private $status_last_write = 0;
102
103 /**
104 * Initialize ajax callback
105 *
106 * @since 1.7.5
107 */
108 public function __construct() {
109 add_action( 'wp_ajax_wpfnl_import_funnels', [ $this, 'schedule_import_funnels' ] );
110 add_action( 'wp_ajax_nopriv_wpfnl_import_funnels', [ $this, 'schedule_import_funnels' ] );
111 add_action( 'wp_ajax_wpfnl_hide_import_funnel_notice', [ $this, 'hide_import_funnel_notice' ] );
112 add_action( 'wp_ajax_nopriv_wpfnl_hide_import_funnel_notice', [ $this, 'hide_import_funnel_notice' ] );
113 // Two accepted args: the job carries a run id alongside the file so a poller can tell
114 // its own import from one started elsewhere. Jobs queued before 2.5.16 fire with one
115 // argument, which `import_funnels()` handles via its default.
116 add_action( 'wpfnl_import_funnels', [ $this, 'import_funnels' ], 10, 2 );
117 add_filter( 'wpfnl_notices', [ $this, 'add_funnel_notice' ] );
118 }
119
120
121 /**
122 * Read the import status record.
123 *
124 * The Action Scheduler worker and the polling request are different PHP processes. On a
125 * site with a non-shared persistent object cache the reader would otherwise keep serving
126 * its own cached copy and the progress bar would sit frozen at 0%, so the cached value is
127 * dropped before every read.
128 *
129 * @return array The status record, or an empty array when no import has ever run.
130 * @since 2.5.16
131 */
132 public static function get_import_status() {
133 wp_cache_delete( self::STATUS_OPTION, 'options' );
134 $status = get_option( self::STATUS_OPTION, [] );
135
136 return is_array( $status ) ? $status : [];
137 }
138
139
140 /**
141 * Merge a patch into the status record and persist it.
142 *
143 * Every write stamps `updated`, which is what stall detection reads — so a write must
144 * happen on every meaningful transition, not only when a counter changes.
145 *
146 * @param array $patch Keys to merge into the current record.
147 *
148 * @return void
149 * @since 2.5.16
150 */
151 private function set_import_status( $patch ) {
152 if( !is_array( $patch ) ) {
153 return;
154 }
155
156 $this->status = array_merge( $this->status, $patch );
157 $this->status[ 'updated' ] = time();
158
159 $total = isset( $this->status[ 'total_steps' ] ) ? (int) $this->status[ 'total_steps' ] : 0;
160 $done = isset( $this->status[ 'done_steps' ] ) ? (int) $this->status[ 'done_steps' ] : 0;
161
162 // Step counts give a smoother bar than funnel counts; fall back to funnels when the
163 // payload carried no steps at all.
164 if( $total < 1 ) {
165 $total = isset( $this->status[ 'total_funnels' ] ) ? (int) $this->status[ 'total_funnels' ] : 0;
166 $done = isset( $this->status[ 'done_funnels' ] ) ? (int) $this->status[ 'done_funnels' ] : 0;
167 }
168
169 $this->status[ 'pct' ] = $total > 0 ? min( 100, (int) floor( ( $done / $total ) * 100 ) ) : 0;
170
171 update_option( self::STATUS_OPTION, $this->status, false );
172 $this->status_last_write = time();
173 }
174
175
176 /**
177 * Persist the status record only if the throttle window has elapsed.
178 *
179 * Used for per-step updates, where an unthrottled write would mean one `update_option()`
180 * call per step.
181 *
182 * @param array $patch Keys to merge into the current record.
183 * @param bool $force Write regardless of the throttle.
184 *
185 * @return void
186 * @since 2.5.16
187 */
188 private function maybe_set_import_status( $patch, $force = false ) {
189 if( $force || ( time() - $this->status_last_write ) >= self::STATUS_WRITE_INTERVAL ) {
190 $this->set_import_status( $patch );
191 return;
192 }
193
194 // Keep the in-memory record current so the next flush carries the latest counters.
195 $this->status = array_merge( $this->status, is_array( $patch ) ? $patch : [] );
196 }
197
198
199 /**
200 * Normalise a decoded import payload into `[ 'meta' => ..., 'funnels' => ... ]`.
201 *
202 * Exports before 2.5.16 were a bare top-level array of funnels; from schema 2 they are an
203 * envelope object carrying provenance plus a `funnels` list. Both are accepted forever.
204 *
205 * Detection keys on `funnels` rather than `schema_version` for two reasons: `funnels` is
206 * the key actually needed, so a hand-edited file missing the version still imports; and a
207 * legacy payload can never carry a string key `funnels` because it is a list keyed 0..n.
208 *
209 * @param mixed $decoded Decoded JSON payload.
210 *
211 * @return array|\WP_Error `[ 'meta' => array, 'funnels' => array ]` or a WP_Error.
212 * @since 2.5.16
213 */
214 public static function normalize_import_payload( $decoded ) {
215 if( !is_array( $decoded ) || empty( $decoded ) ) {
216 return new \WP_Error( 'invalid_structure', __( 'This file is not a valid WPFunnels export.', 'wpfnl' ) );
217 }
218
219 if( isset( $decoded[ 'funnels' ] ) && is_array( $decoded[ 'funnels' ] ) ) {
220 $schema = isset( $decoded[ 'schema_version' ] ) ? (int) $decoded[ 'schema_version' ] : self::SUPPORTED_SCHEMA_VERSION;
221 $meta = [
222 'schema_version' => $schema,
223 'generator' => isset( $decoded[ 'generator' ] ) ? sanitize_text_field( $decoded[ 'generator' ] ) : '',
224 'plugin_version' => isset( $decoded[ 'plugin_version' ] ) ? sanitize_text_field( $decoded[ 'plugin_version' ] ) : '',
225 'pro_version' => isset( $decoded[ 'pro_version' ] ) ? sanitize_text_field( $decoded[ 'pro_version' ] ) : '',
226 'exported_at' => isset( $decoded[ 'exported_at' ] ) ? sanitize_text_field( $decoded[ 'exported_at' ] ) : '',
227 'source_site' => isset( $decoded[ 'source_site' ] ) ? esc_url_raw( $decoded[ 'source_site' ] ) : '',
228 'legacy' => false,
229 // A newer file warns but still imports: the format is additive and per-funnel
230 // objects are read key by key, so unknown keys are simply ignored. Blocking
231 // would strand people mid-migration for no safety benefit.
232 'forward_compat' => $schema > self::SUPPORTED_SCHEMA_VERSION,
233 ];
234 $funnels = array_values( $decoded[ 'funnels' ] );
235 }
236 else {
237 $meta = [
238 'schema_version' => 1,
239 'generator' => '',
240 'plugin_version' => '',
241 'pro_version' => '',
242 'exported_at' => '',
243 'source_site' => '',
244 'legacy' => true,
245 'forward_compat' => false,
246 ];
247 $funnels = array_values( $decoded );
248 }
249
250 if( empty( $funnels ) ) {
251 return new \WP_Error( 'invalid_structure', __( 'This file is not a valid WPFunnels export.', 'wpfnl' ) );
252 }
253
254 foreach( $funnels as $funnel ) {
255 if( !is_array( $funnel ) ) {
256 return new \WP_Error( 'invalid_structure', __( 'This file is not a valid WPFunnels export.', 'wpfnl' ) );
257 }
258 if( empty( $funnel[ 'funnel_id' ] ) ) {
259 return new \WP_Error( 'invalid_structure', __( 'This file is not a valid WPFunnels export.', 'wpfnl' ) );
260 }
261 if( !isset( $funnel[ 'funnel_meta' ] ) || !is_array( $funnel[ 'funnel_meta' ] ) ) {
262 return new \WP_Error( 'invalid_structure', __( 'This file is not a valid WPFunnels export.', 'wpfnl' ) );
263 }
264 if( !isset( $funnel[ 'steps_data' ] ) || !is_array( $funnel[ 'steps_data' ] ) ) {
265 return new \WP_Error( 'invalid_structure', __( 'This file is not a valid WPFunnels export.', 'wpfnl' ) );
266 }
267 }
268
269 return [
270 'meta' => $meta,
271 'funnels' => $funnels,
272 ];
273 }
274
275
276 /**
277 * Derive the effective state of a status record.
278 *
279 * Stall detection has to live in the reader: the process that would have marked itself
280 * stalled is precisely the one that died. This is a pure derivation — safe to run on every
281 * poll and from concurrent requests.
282 *
283 * @param array $record Status record from `get_import_status()`.
284 *
285 * @return array `[ 'state' => string, 'reason' => string ]`.
286 * @since 2.5.16
287 */
288 public static function derive_import_state( $record ) {
289 if( empty( $record ) || empty( $record[ 'state' ] ) ) {
290 return [ 'state' => 'idle', 'reason' => '' ];
291 }
292
293 if( in_array( $record[ 'state' ], [ 'completed', 'failed' ], true ) ) {
294 return [ 'state' => $record[ 'state' ], 'reason' => '' ];
295 }
296
297 // Arg-less on purpose: this matches any pending `wpfnl_import_funnels` action, which is
298 // what makes it a global lock. Passing args would break the lock semantics.
299 $scheduled = function_exists( 'as_has_scheduled_action' ) ? as_has_scheduled_action( 'wpfnl_import_funnels' ) : false;
300 $age = time() - ( isset( $record[ 'updated' ] ) ? (int) $record[ 'updated' ] : 0 );
301
302 if( !$scheduled && $age > self::VANISH_GRACE ) {
303 return [ 'state' => 'stalled', 'reason' => 'job_vanished' ];
304 }
305
306 if( $scheduled && 'queued' === $record[ 'state' ] && $age > self::QUEUE_TIMEOUT ) {
307 return [ 'state' => 'stalled', 'reason' => 'never_started' ];
308 }
309
310 if( $scheduled && $age > self::STALL_TIMEOUT ) {
311 return [ 'state' => 'stalled', 'reason' => 'no_progress' ];
312 }
313
314 return [ 'state' => 'running', 'reason' => '' ];
315 }
316
317
318 /**
319 * Build the payload served by the import-status REST route.
320 *
321 * @param string $run_id Run id the caller believes it is watching. When it does not match
322 * the stored record the caller is told its run was superseded, so it
323 * stops polling rather than narrating somebody else's import.
324 *
325 * @return array
326 * @since 2.5.16
327 */
328 public static function get_status_payload( $run_id = '' ) {
329 $record = self::get_import_status();
330
331 if( empty( $record ) ) {
332 return [ 'state' => 'idle', 'run_id' => '', 'pct' => 0, 'label' => '' ];
333 }
334
335 $stored_run_id = isset( $record[ 'run_id' ] ) ? $record[ 'run_id' ] : '';
336
337 if( $run_id && $stored_run_id && $run_id !== $stored_run_id ) {
338 return [ 'state' => 'superseded', 'run_id' => $stored_run_id, 'pct' => 0, 'label' => '' ];
339 }
340
341 $derived = self::derive_import_state( $record );
342 $updated = isset( $record[ 'updated' ] ) ? (int) $record[ 'updated' ] : 0;
343
344 return [
345 'state' => $derived[ 'state' ],
346 'reason' => $derived[ 'reason' ],
347 'run_id' => $stored_run_id,
348 'pct' => isset( $record[ 'pct' ] ) ? (int) $record[ 'pct' ] : 0,
349 'label' => isset( $record[ 'label' ] ) ? $record[ 'label' ] : '',
350 'message' => isset( $record[ 'message' ] ) ? $record[ 'message' ] : '',
351 'total_funnels' => isset( $record[ 'total_funnels' ] ) ? (int) $record[ 'total_funnels' ] : 0,
352 'done_funnels' => isset( $record[ 'done_funnels' ] ) ? (int) $record[ 'done_funnels' ] : 0,
353 'total_steps' => isset( $record[ 'total_steps' ] ) ? (int) $record[ 'total_steps' ] : 0,
354 'done_steps' => isset( $record[ 'done_steps' ] ) ? (int) $record[ 'done_steps' ] : 0,
355 'imported' => isset( $record[ 'imported' ] ) ? array_values( (array) $record[ 'imported' ] ) : [],
356 'failed' => isset( $record[ 'failed' ] ) ? array_values( (array) $record[ 'failed' ] ) : [],
357 'source' => isset( $record[ 'source' ] ) ? (array) $record[ 'source' ] : [],
358 'funnels_url' => add_query_arg( [ 'page' => WPFNL_FUNNEL_PAGE_SLUG ], admin_url( 'admin.php' ) ),
359 // A fresh page load uses this to decide between resuming a live poll and showing a
360 // dismissible result card for a run that finished a while ago.
361 'is_stale' => in_array( $derived[ 'state' ], [ 'completed', 'failed' ], true )
362 && ( time() - $updated ) > self::STALE_RESULT_AFTER,
363 ];
364 }
365
366
367 /**
368 * Clear a stuck import so the user is not locked out.
369 *
370 * Without this, a single failed run leaves `as_has_scheduled_action()` returning true
371 * forever and every later import is refused with "Import already scheduled".
372 *
373 * @return array
374 * @since 2.5.16
375 */
376 public static function reset_import() {
377 if( function_exists( 'as_unschedule_all_actions' ) ) {
378 as_unschedule_all_actions( 'wpfnl_import_funnels' );
379 }
380
381 $record = self::get_import_status();
382
383 if( !empty( $record[ 'file' ] ) ) {
384 self::delete_import_file( $record[ 'file' ] );
385 }
386
387 delete_option( self::STATUS_OPTION );
388 delete_option( 'wpfnl_import_notice' );
389
390 return [ 'success' => true ];
391 }
392
393
394 /**
395 * Delete a file, but only when it really sits inside the import directory.
396 *
397 * The path comes from a stored record, so it is treated as untrusted: a poisoned record
398 * must not be able to unlink arbitrary files.
399 *
400 * @param string $file Absolute path to remove.
401 *
402 * @return bool Whether the file was removed.
403 * @since 2.5.16
404 */
405 private static function delete_import_file( $file ) {
406 $upload = wp_upload_dir();
407 $base = realpath( trailingslashit( $upload[ 'basedir' ] ) . 'wpfunnels/import' );
408 $target = realpath( $file );
409
410 if( !$base || !$target ) {
411 return false;
412 }
413
414 if( 0 !== strpos( $target, $base . DIRECTORY_SEPARATOR ) ) {
415 return false;
416 }
417
418 return unlink( $target );
419 }
420
421 /**
422 * Adds a custom admin notice to the WordPress admin dashboard.
423 *
424 * This function is a callback used to add a custom notice to the admin dashboard.
425 * The notice is added to the existing array of notices.
426 *
427 * @param array $notices The array of existing admin notices.
428 *
429 * @return array The updated array of admin notices with the added custom notice.
430 * @since 1.9.7
431 */
432 public function add_funnel_notice( $notices ) {
433 $notices[ 'WPFunnels\Import\Wpfnl_Import' ]= 'funnel_import_notice';
434 return $notices;
435 }
436
437 /**
438 * Displays a notice related to funnel import status.
439 *
440 * This static method is used to display a notice related to the funnel import status in the WordPress admin area.
441 * It checks whether the import status option is not empty and whether the current screen context allows displaying
442 * the notice. If the conditions are met, a notice is generated using the provided options and displayed in the admin area.
443 * The notice can be dismissed by the user.
444 *
445 * @since 1.9.7
446 */
447 public static function funnel_import_notice() {
448 $import_status = get_option( 'wpfnl_import_notice', '' );
449 if( !empty( $import_status ) ) {
450 global $current_screen;
451 // `$current_screen` is not set on every admin request that reaches admin_notices,
452 // so read `parent_base` defensively rather than off a possible null.
453 $parent_base = isset( $current_screen->parent_base ) ? $current_screen->parent_base : '';
454 $current_page = isset( $_GET[ 'page' ] ) ? sanitize_text_field( wp_unslash( $_GET[ 'page' ] ) ) : '';
455 $disabled_notice_page = [ 'wp_funnels' ];
456 if( 'plugins' === $parent_base || '' === $current_page || in_array( $current_page, $disabled_notice_page, true ) ) {
457 $nonce_field = "<input type='hidden' id='wpfnl-import-nonce' value='" . esc_attr( wp_create_nonce('wpfnl-admin') ) . "'>";
458 $options = array(
459 'id' => 'wpfnl-import-notice',
460 'title' => 'wpfunnels-basic',
461 'description' => "<span>{$import_status}</span>{$nonce_field}",
462 'classes' => [ 'notice', 'notice-info', 'wpfnl-import-notice' ],
463 'type' => 'update-plugin',
464 'dismissible' => true,
465 'icon' => '',
466 );
467 Admin_Notice::print_notice( $options );
468 }
469 }
470 }
471
472 /**
473 * Schedule the import of funnels as a background process.
474 *
475 * This method is responsible for scheduling the import of funnels as a background process. It checks if the 'wpfnl_import_funnels'
476 * action has not already been scheduled, and if so, it prepares the necessary file for import and schedules the background process.
477 * The process involves uploading the imported file, updating the import notice, and enqueueing the 'wpfnl_import_funnels' action
478 * to be executed asynchronously using a background processing mechanism (such as WP Async Task).
479 *
480 * @since 1.9.7
481 * @updated 2.5.14 - Added security checks
482 */
483 public function schedule_import_funnels() {
484 // Security: Check if user is logged in
485 if ( ! is_user_logged_in() ) {
486 wp_send_json_error( [ 'message' => __( 'Unauthorized access.', 'wpfnl' ) ], 401 );
487 wp_die();
488 }
489
490 // Security: Check user capability
491 if ( ! current_user_can( 'manage_options' ) ) {
492 wp_send_json_error( [ 'message' => __( 'You do not have permission to import funnels.', 'wpfnl' ) ], 403 );
493 wp_die();
494 }
495
496 // Security: Verify nonce
497 if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['nonce'] ) ), 'wpfnl_import_funnels_nonce' ) ) {
498 wp_send_json_error( [ 'message' => __( 'Security check failed.', 'wpfnl' ) ], 403 );
499 wp_die();
500 }
501
502 $result = $this->schedule_import();
503
504 if( is_wp_error( $result ) ) {
505 $data = (array) $result->get_error_data();
506 $status = isset( $data[ 'status' ] ) ? (int) $data[ 'status' ] : 400;
507 unset( $data[ 'status' ] );
508
509 wp_send_json_error(
510 array_merge(
511 [
512 'code' => $result->get_error_code(),
513 'message' => $result->get_error_message(),
514 ],
515 $data
516 ),
517 $status
518 );
519 wp_die();
520 }
521
522 wp_send_json_success( $result );
523 wp_die();
524 }
525
526
527 /**
528 * Queue an uploaded payload for import and seed its status record.
529 *
530 * Shared by the admin-ajax handler above and the REST route the React funnel list posts
531 * to, so both entry points enforce the same lock, upload the file the same way and report
532 * the same run id. Callers are responsible for their own capability and nonce checks —
533 * this method does the work, not the gatekeeping.
534 *
535 * @return array|\WP_Error `[ 'message' => string, 'run_id' => string ]`, or an error whose
536 * data carries the HTTP `status` to answer with.
537 * @since 3.13.4
538 */
539 public function schedule_import() {
540 // A previous run that died leaves its action pending forever, which used to refuse
541 // every later import with "Import already scheduled" and no way out. Clear the lock
542 // when the stored record says the run is stalled; only refuse when it is genuinely
543 // still moving, and then tell the client it may reset.
544 if( function_exists( 'as_has_scheduled_action' ) && as_has_scheduled_action( 'wpfnl_import_funnels' ) ) {
545 $derived = self::derive_import_state( self::get_import_status() );
546
547 if( 'running' === $derived[ 'state' ] ) {
548 $record = self::get_import_status();
549
550 return new \WP_Error(
551 'import_in_progress',
552 __( 'An import is already running. Wait for it to finish, or cancel it to start a new one.', 'wpfnl' ),
553 [
554 'status' => 409,
555 'run_id' => isset( $record[ 'run_id' ] ) ? $record[ 'run_id' ] : '',
556 'can_reset' => true,
557 ]
558 );
559 }
560
561 self::reset_import();
562 }
563
564 $file = $this->upload_imported_file();
565
566 if( is_wp_error( $file ) ) {
567 return new \WP_Error( $file->get_error_code(), $file->get_error_message(), [ 'status' => 400 ] );
568 }
569
570 if( !file_exists( $file ) ) {
571 return new \WP_Error( 'upload_failed', __( 'Failed to upload file.', 'wpfnl' ), [ 'status' => 400 ] );
572 }
573
574 $run_id = uniqid( 'imp_', true );
575 $totals = $this->count_payload_totals( $file );
576
577 // Seed the record before enqueuing so a poll that arrives before the worker starts
578 // still finds a run to report on.
579 $this->status = [];
580 $this->status_last_write = 0;
581 $this->set_import_status(
582 [
583 'run_id' => $run_id,
584 'state' => 'queued',
585 'started' => time(),
586 'file' => $file,
587 'user_id' => get_current_user_id(),
588 'source' => $totals[ 'source' ],
589 'total_funnels' => $totals[ 'funnels' ],
590 'done_funnels' => 0,
591 'total_steps' => $totals[ 'steps' ],
592 'done_steps' => 0,
593 'label' => __( 'Preparing import…', 'wpfnl' ),
594 'message' => '',
595 'imported' => [],
596 'failed' => [],
597 ]
598 );
599
600 update_option( 'wpfnl_import_notice', __( 'Funnels import in-progress...', 'wpfnl' ) );
601 as_enqueue_async_action( 'wpfnl_import_funnels', [ $file, $run_id ], 'wpfunnels-import' );
602
603 return [
604 'message' => __( 'Funnel import scheduled successfully.', 'wpfnl' ),
605 'run_id' => $run_id,
606 ];
607 }
608
609
610 /**
611 * Count the funnels and steps in an uploaded payload so progress has a denominator.
612 *
613 * @param string $file Absolute path to the validated import file.
614 *
615 * @return array `[ 'funnels' => int, 'steps' => int, 'source' => array ]`.
616 * @since 2.5.16
617 */
618 private function count_payload_totals( $file ) {
619 $empty = [ 'funnels' => 0, 'steps' => 0, 'source' => [] ];
620
621 if( !file_exists( $file ) ) {
622 return $empty;
623 }
624
625 $decoded = json_decode( file_get_contents( $file ), true );
626 $payload = self::normalize_import_payload( $decoded );
627
628 if( is_wp_error( $payload ) ) {
629 return $empty;
630 }
631
632 $steps = 0;
633 foreach( $payload[ 'funnels' ] as $funnel ) {
634 $steps += isset( $funnel[ 'steps_data' ] ) ? count( $funnel[ 'steps_data' ] ) : 0;
635 }
636
637 return [
638 'funnels' => count( $payload[ 'funnels' ] ),
639 'steps' => $steps,
640 'source' => $payload[ 'meta' ],
641 ];
642 }
643
644 /**
645 * Uploads an imported file to the specified directory in the WordPress uploads folder.
646 *
647 * This private method is used to upload an imported file to a specific directory within the WordPress uploads folder.
648 * It ensures that the target directory exists and creates it if not present. The method reads the temporary uploaded
649 * file, extracts its content, and saves it to the target file location. The saved file's name is based on the original
650 * uploaded filename, and if no filename is provided, a default name is used.
651 *
652 * @return string|WP_Error The full path to the uploaded file on success, or WP_Error on failure.
653 * @since 1.9.7
654 * @updated 2.5.14 - Added comprehensive security validation
655 */
656 private function upload_imported_file() {
657 // Security: Validate file upload exists
658 if ( ! isset( $_FILES['uploaded_file'] ) || ! isset( $_FILES['uploaded_file']['tmp_name'] ) ) {
659 return new \WP_Error( 'no_file', __( 'No file uploaded.', 'wpfnl' ) );
660 }
661
662 // Security: Check for upload errors
663 if ( $_FILES['uploaded_file']['error'] !== UPLOAD_ERR_OK ) {
664 return new \WP_Error( 'upload_error', __( 'File upload failed.', 'wpfnl' ) );
665 }
666
667 $temp_file = sanitize_text_field( wp_unslash( $_FILES['uploaded_file']['tmp_name'] ) );
668 $file_name = sanitize_file_name( wp_unslash( $_FILES['uploaded_file']['name'] ) );
669 $file_size = intval( $_FILES['uploaded_file']['size'] );
670
671 // Security: Validate file is not empty
672 if ( empty( $temp_file ) || ! file_exists( $temp_file ) ) {
673 return new \WP_Error( 'invalid_file', __( 'Invalid file upload.', 'wpfnl' ) );
674 }
675
676 // Security: Validate file size (max 10MB)
677 $max_size = 10 * 1024 * 1024; // 10MB
678 if ( $file_size > $max_size || $file_size === 0 ) {
679 return new \WP_Error( 'file_too_large', __( 'File size must be between 1 byte and 10MB.', 'wpfnl' ) );
680 }
681
682 // Security: Validate file extension
683 $file_ext = strtolower( pathinfo( $file_name, PATHINFO_EXTENSION ) );
684 if ( $file_ext !== 'json' ) {
685 return new \WP_Error( 'invalid_extension', __( 'Only JSON files are allowed.', 'wpfnl' ) );
686 }
687
688 // Security: Validate MIME type
689 $file_type = wp_check_filetype( $file_name, [ 'json' => 'application/json' ] );
690 if ( ! $file_type['ext'] ) {
691 return new \WP_Error( 'invalid_mime', __( 'Invalid file type. Only JSON files are allowed.', 'wpfnl' ) );
692 }
693
694 // Security: Read and validate JSON content
695 $content = file_get_contents( $temp_file );
696 if ( $content === false ) {
697 return new \WP_Error( 'read_error', __( 'Failed to read file content.', 'wpfnl' ) );
698 }
699
700 // Security: Validate JSON structure
701 $decoded = json_decode( $content, true );
702 if ( json_last_error() !== JSON_ERROR_NONE ) {
703 return new \WP_Error( 'invalid_json', __( 'Invalid JSON format.', 'wpfnl' ) );
704 }
705
706 // Security: Check for PHP open tags in content.
707 // NOTE: `<script` and `javascript:` are deliberately NOT checked here. Funnel steps
708 // legitimately carry marketing tags (Meta Pixel, gtag) and page-builder link fields
709 // inside their page content, and scanning the raw payload for them rejected valid
710 // exports. That content is stored as post data and is never executed by the importer.
711 if ( preg_match( '/<\?php|<\?=/i', $content ) ) {
712 return new \WP_Error( 'php_code', __( 'File contains PHP code and cannot be imported.', 'wpfnl' ) );
713 }
714
715 // Security: Confirm the payload is actually a funnel export rather than arbitrary JSON.
716 // Accepts both the schema 2 envelope and the legacy bare array.
717 $payload = self::normalize_import_payload( $decoded );
718 if ( is_wp_error( $payload ) ) {
719 return $payload;
720 }
721
722 $path = wp_upload_dir();
723 $path = $path['basedir'] . '/wpfunnels/import/';
724
725 // Make directory if not exist
726 if ( ! file_exists( $path ) ) {
727 wp_mkdir_p( $path );
728 }
729
730 // Security: Use unique filename to prevent overwrites
731 $unique_filename = wp_unique_filename( $path, $file_name );
732 $file_path = $path . $unique_filename;
733
734 // Security: Write file with restricted permissions
735 $bytes_written = file_put_contents( $file_path, $content );
736 if ( $bytes_written === false ) {
737 return new \WP_Error( 'write_error', __( 'Failed to save file.', 'wpfnl' ) );
738 }
739
740 // Security: Set proper file permissions (read/write for owner only)
741 chmod( $file_path, 0600 );
742
743 return $file_path;
744 }
745
746
747 /**
748 * Unserialize an imported meta value without instantiating PHP objects.
749 *
750 * Behaves like `maybe_unserialize()` but passes `allowed_classes => false`, so a crafted
751 * `O:` payload in an import file resolves to `__PHP_Incomplete_Class` instead of a real
752 * object. That blocks PHP object injection (POP chains via `__wakeup`/`__destruct`),
753 * which is the one genuinely executable path in the import flow.
754 *
755 * @param mixed $original The value to unserialize.
756 *
757 * @return mixed The unserialized value, or the original value when it is not serialized.
758 * @since 2.5.15
759 */
760 private function safe_maybe_unserialize( $original ) {
761 if ( ! is_string( $original ) ) {
762 return $original;
763 }
764
765 $trimmed = trim( $original );
766 if ( ! is_serialized( $trimmed ) ) {
767 return $original;
768 }
769
770 $result = @unserialize( $trimmed, [ 'allowed_classes' => false ] ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
771
772 // `unserialize()` returns false both on failure and for the serialized value `false`.
773 if ( false === $result && 'b:0;' !== $trimmed ) {
774 return $original;
775 }
776
777 return $result;
778 }
779
780
781 /**
782 * Determine which page builder a step was actually built with.
783 *
784 * Resolved from the step's own exported meta rather than `get_builder_type()`, which reads
785 * the destination site's WPFunnels setting (default `gutenberg`) and says nothing about the
786 * funnel being imported. Relying on that setting meant an Elementor funnel imported into a
787 * site that had not switched its builder skipped Elementor's post-import pass entirely,
788 * leaving images unmapped and the source site's render caches in place.
789 *
790 * Mirrors the detection order already used by `Wpfnl_functions::get_page_builder_by_step_id()`.
791 *
792 * @param array $step_data Exported step payload, expected to carry a `meta` array.
793 *
794 * @return string Builder slug, falling back to the site setting when nothing is detected.
795 * @since 2.5.15
796 */
797 private function detect_step_builder( $step_data ) {
798 $meta = isset( $step_data['meta'] ) && is_array( $step_data['meta'] ) ? $step_data['meta'] : [];
799
800 $value = function( $key ) use ( $meta ) {
801 return isset( $meta[ $key ][0] ) ? $meta[ $key ][0] : '';
802 };
803
804 if ( ! empty( $value( '_elementor_edit_mode' ) ) || ! empty( $value( '_elementor_data' ) ) ) {
805 return 'elementor';
806 }
807 if ( 'on' === $value( '_et_pb_use_builder' ) ) {
808 return 'divi-builder';
809 }
810 if ( 'bricks' === $value( '_bricks_editor_mode' ) ) {
811 return 'bricks';
812 }
813 if ( ! empty( $value( 'ct_builder_shortcodes' ) ) ) {
814 return 'oxygen';
815 }
816
817 return Wpfnl_functions::get_builder_type();
818 }
819
820
821 /**
822 * Run the page-builder specific post-import pass for a freshly created step.
823 *
824 * For Elementor this is what remaps media through `Control_Media::on_import()`, pulling
825 * remote images into this site's library instead of leaving them pointed at the source.
826 *
827 * @param int $step_id Newly created step id.
828 * @param string $builder_type Builder slug from `detect_step_builder()`.
829 *
830 * @return void
831 * @since 2.5.15
832 */
833 private function run_builder_post_import( $step_id, $builder_type ) {
834 // Every source below fetches remote assets while processing the template.
835 if ( ! ini_get( 'allow_url_fopen' ) ) {
836 return;
837 }
838
839 if ( 'gutenberg' === $builder_type && class_exists( '\WPFunnels\Batch\Gutenberg\Wpfnl_Gutenberg_Source' ) ) {
840 $gutenberg_source = new \WPFunnels\Batch\Gutenberg\Wpfnl_Gutenberg_Source;
841 $gutenberg_source->import_single_template( $step_id );
842 }
843
844 if ( 'elementor' === $builder_type && class_exists( '\Elementor\Plugin' ) && class_exists( '\WPFunnels\Batch\Elementor\Wpfnl_Elementor_Source' ) ) {
845 $elementor_source = new \WPFunnels\Batch\Elementor\Wpfnl_Elementor_Source;
846 $elementor_source->import_single_template( $step_id );
847 // Flushed once after the whole import instead of per step; clear_cache() globs and
848 // unlinks the entire Elementor CSS directory on every call.
849 $this->needs_elementor_cache_flush = true;
850 }
851
852 if ( 'divi-builder' === $builder_type && class_exists( '\WPFunnels\Batch\Divi\Wpfnl_Divi_Source' ) ) {
853 $divi_source = new \WPFunnels\Batch\Divi\Wpfnl_Divi_Source;
854 $divi_source->import_single_template( $step_id );
855 }
856
857 if ( 'oxygen' === $builder_type && class_exists( '\WPFunnels\Batch\Oxygen\Wpfnl_Oxygen_Source' ) ) {
858 $oxygen_source = new \WPFunnels\Batch\Oxygen\Wpfnl_Oxygen_Source;
859 $oxygen_source->import_single_template( $step_id );
860 }
861 }
862
863
864 /**
865 * Import Funnel data and create
866 *
867 * @param string $file Absolute path to the uploaded import file.
868 * @param string $run_id Run id minted when the job was scheduled. Empty for jobs queued by
869 * a version before 2.5.16, which fire this hook with one argument —
870 * in that case a run id is minted here so the UI is not left blind.
871 *
872 * @return true
873 * @since 1.7.5
874 */
875 public function import_funnels( $file, $run_id = '' ) {
876 $file_data = [];
877 if( file_exists( $file ) ) {
878 $file_data = json_decode( file_get_contents( $file ), true );
879 unlink( $file );
880 }
881 $failed = false;
882
883 $payload = self::normalize_import_payload( $file_data );
884
885 if( is_wp_error( $payload ) ) {
886 $this->finish_import( $run_id, true, $payload->get_error_message() );
887 return true;
888 }
889
890 $file_data = $payload[ 'funnels' ];
891
892 // Adopt the record seeded at schedule time, or start one when this job predates the
893 // run-id argument. Either way the worker owns the record from here on.
894 $this->status = self::get_import_status();
895 if( !$run_id || empty( $this->status[ 'run_id' ] ) || $this->status[ 'run_id' ] !== $run_id ) {
896 $run_id = $run_id ? $run_id : uniqid( 'imp_', true );
897 $this->status = [
898 'run_id' => $run_id,
899 'started' => time(),
900 'file' => $file,
901 'user_id' => get_current_user_id(),
902 'source' => $payload[ 'meta' ],
903 'imported' => [],
904 'failed' => [],
905 ];
906 }
907
908 $total_steps = 0;
909 foreach( $file_data as $data ) {
910 $total_steps += isset( $data[ 'steps_data' ] ) ? count( $data[ 'steps_data' ] ) : 0;
911 }
912
913 $done_funnels = 0;
914 $done_steps = 0;
915 $failed_funnels = [];
916 // Per-cap latch: once the free plan's limit is reached, every later funnel of that
917 // type is skipped without re-running the count query.
918 $limit_hit = [ 'funnel' => false, 'store_checkout' => false ];
919
920 $this->set_import_status(
921 [
922 'state' => 'running',
923 'total_funnels' => count( $file_data ),
924 'done_funnels' => 0,
925 'total_steps' => $total_steps,
926 'done_steps' => 0,
927 'label' => __( 'Starting import…', 'wpfnl' ),
928 ]
929 );
930
931 if( is_array( $file_data ) ) {
932 $is_pro = apply_filters( 'wpfunnels/is_pro_license_activated', false );
933 foreach( $file_data as $data ) {
934 // normalize_import_payload() already rejects a payload with a funnel missing
935 // its id, so this is belt-and-braces — record and skip rather than abort.
936 if( empty( $data[ 'funnel_id' ] ) ) {
937 $failed = true;
938 $failed_funnels = $this->append_failure( $failed_funnels, $data, 'invalid_funnel_data', __( 'Invalid funnel data.', 'wpfnl' ) );
939 $this->set_import_status( [ 'failed' => $failed_funnels ] );
940 continue;
941 }
942
943 $this->set_import_status(
944 [
945 'label' => sprintf(
946 /* translators: 1: funnel name, 2: current funnel number, 3: total funnels */
947 __( 'Importing "%1$s" (%2$d of %3$d)…', 'wpfnl' ),
948 isset( $data[ 'funnel_name' ] ) ? $data[ 'funnel_name' ] : __( 'Untitled', 'wpfnl' ),
949 $done_funnels + 1,
950 count( $file_data )
951 ),
952 ]
953 );
954
955 // Free-plan caps skip this funnel rather than aborting the run, so the rest
956 // of the file still imports. Once a cap is hit every later funnel of that type
957 // hits it too, so the count query is not repeated.
958 if( !$is_pro ) {
959 $imported_type = isset( $data['funnel_meta']['_wpfnl_funnel_type'][0] ) ? $data['funnel_meta']['_wpfnl_funnel_type'][0] : '';
960 $is_importing_sc = ( 'store_checkout' === $imported_type );
961 $cap_key = $is_importing_sc ? 'store_checkout' : 'funnel';
962
963 if( !$limit_hit[ $cap_key ] ) {
964 $current_count = $is_importing_sc
965 ? \WPFunnels\Wpfnl_functions::count_store_checkout_funnels()
966 : \WPFunnels\Wpfnl_functions::count_non_store_checkout_funnels();
967
968 if( $current_count >= 3 ) {
969 $limit_hit[ $cap_key ] = true;
970 }
971 }
972
973 if( $limit_hit[ $cap_key ] ) {
974 $failed = true;
975 $failed_funnels = $this->append_failure(
976 $failed_funnels,
977 $data,
978 $is_importing_sc ? 'free_limit_store_checkouts' : 'free_limit_funnels',
979 $is_importing_sc
980 ? __( 'Skipped: the free plan allows 3 store checkouts.', 'wpfnl' )
981 : __( 'Skipped: the free plan allows 3 funnels.', 'wpfnl' )
982 );
983 $this->set_import_status( [ 'failed' => $failed_funnels ] );
984 continue;
985 }
986 }
987
988 try {
989 $result = $this->import_single_funnel( $data, $done_steps );
990 }
991 catch( \Throwable $e ) {
992 // One malformed funnel must not take the whole file down with it.
993 $failed = true;
994 $failed_funnels = $this->append_failure( $failed_funnels, $data, 'exception', $e->getMessage() );
995 $this->set_import_status( [ 'failed' => $failed_funnels ] );
996 continue;
997 }
998
999 if( is_wp_error( $result ) ) {
1000 $failed = true;
1001 $failed_funnels = $this->append_failure( $failed_funnels, $data, $result->get_error_code(), $result->get_error_message() );
1002 $this->set_import_status( [ 'failed' => $failed_funnels ] );
1003 continue;
1004 }
1005
1006 if( $result[ 'failed_steps' ] > 0 ) {
1007 $failed = true;
1008 }
1009
1010 $done_funnels++;
1011 $imported = isset( $this->status[ 'imported' ] ) ? (array) $this->status[ 'imported' ] : [];
1012 $imported[] = [
1013 'source_id' => $data[ 'funnel_id' ],
1014 'new_id' => $result[ 'funnel_id' ],
1015 'name' => get_the_title( $result[ 'funnel_id' ] ),
1016 'steps' => $result[ 'steps' ],
1017 'failed_steps' => $result[ 'failed_steps' ],
1018 'status' => $result[ 'failed_steps' ] > 0 ? 'partial' : 'ok',
1019 'edit_url' => add_query_arg(
1020 [
1021 'page' => WPFNL_EDIT_FUNNEL_SLUG,
1022 'id' => $result[ 'funnel_id' ],
1023 'step_id' => $result[ 'first_step_id' ],
1024 ],
1025 admin_url( 'admin.php' )
1026 ),
1027 ];
1028
1029 $this->set_import_status(
1030 [
1031 'done_funnels' => $done_funnels,
1032 'done_steps' => $done_steps,
1033 'imported' => $imported,
1034 ]
1035 );
1036 }
1037 }
1038
1039 // Flush Elementor's file cache once for the whole import. clear_cache() unlinks the
1040 // entire Elementor CSS directory and runs three delete_post_meta_by_key() sweeps, so
1041 // doing it per step multiplied that by the step count.
1042 if( $this->needs_elementor_cache_flush && class_exists( '\Elementor\Plugin' ) ) {
1043 Plugin::$instance->files_manager->clear_cache();
1044 $this->needs_elementor_cache_flush = false;
1045 }
1046
1047 $this->finish_import( $run_id, $failed, $this->build_outcome_message( $done_funnels, $failed_funnels ) );
1048 return true;
1049 }
1050
1051
1052 /**
1053 * Compose the closing message from what actually happened.
1054 *
1055 * A single fixed string could not tell "all nine imported" apart from "two imported, seven
1056 * skipped", which is the case people most need to see.
1057 *
1058 * @param int $imported_count Funnels created.
1059 * @param array $failures Collected per-funnel failures.
1060 *
1061 * @return string
1062 * @since 2.5.16
1063 */
1064 private function build_outcome_message( $imported_count, $failures ) {
1065 $failed_count = count( $failures );
1066
1067 if( 0 === $failed_count ) {
1068 return __( 'The funnels import process has been successfully completed.', 'wpfnl' );
1069 }
1070
1071 if( 0 === $imported_count ) {
1072 // When every funnel failed for the same reason, that reason is the whole story.
1073 $reasons = array_unique( wp_list_pluck( $failures, 'message' ) );
1074 if( 1 === count( $reasons ) ) {
1075 return reset( $reasons );
1076 }
1077
1078 return sprintf(
1079 /* translators: %d: number of funnels that could not be imported */
1080 _n( '%d funnel could not be imported.', '%d funnels could not be imported.', $failed_count, 'wpfnl' ),
1081 $failed_count
1082 );
1083 }
1084
1085 return sprintf(
1086 /* translators: 1: number imported, 2: total in the file, 3: number that failed */
1087 __( '%1$d of %2$d funnels imported. %3$d could not be imported.', 'wpfnl' ),
1088 $imported_count,
1089 $imported_count + $failed_count,
1090 $failed_count
1091 );
1092 }
1093
1094
1095 /**
1096 * Append a per-funnel failure record.
1097 *
1098 * @param array $failures Existing failures.
1099 * @param array $data The funnel payload that failed.
1100 * @param string $code Machine-readable reason.
1101 * @param string $message Human-readable reason.
1102 *
1103 * @return array
1104 * @since 2.5.16
1105 */
1106 private function append_failure( $failures, $data, $code, $message ) {
1107 $failures[] = [
1108 'source_id' => isset( $data[ 'funnel_id' ] ) ? $data[ 'funnel_id' ] : 0,
1109 'name' => isset( $data[ 'funnel_name' ] ) ? $data[ 'funnel_name' ] : __( 'Untitled funnel', 'wpfnl' ),
1110 'code' => $code,
1111 'message' => $message,
1112 ];
1113
1114 return $failures;
1115 }
1116
1117
1118 /**
1119 * Create one funnel and all of its steps.
1120 *
1121 * Extracted from the run loop so a `\Throwable` from any single funnel can be caught and
1122 * turned into a skip, instead of taking down the whole import.
1123 *
1124 * @param array $data One funnel entry from the payload.
1125 * @param int $done_steps Running step counter, updated by reference so progress keeps
1126 * advancing across funnels.
1127 *
1128 * @return array|\WP_Error
1129 * @since 2.5.16
1130 */
1131 private function import_single_funnel( $data, &$done_steps ) {
1132 $funnel = Wpfnl::$instance->funnel_store;
1133 $exclude_meta = array( '_is_imported', '_steps_order', '_steps', '_funnel_data' );
1134
1135 $funnel_data = [];
1136 $funnel_name = $data[ 'funnel_name' ] ?? 'New Funnel';
1137 $funnel_id = $funnel->create( $funnel_name );
1138
1139 if( !$funnel_id || is_wp_error( $funnel_id ) ) {
1140 return new \WP_Error( 'funnel_create_failed', __( 'The funnel could not be created.', 'wpfnl' ) );
1141 }
1142
1143 $funnel->update_meta( $funnel_id, '_is_imported', 'yes' );
1144
1145 $_new_step_ids = [];
1146 $_new_step_order = [];
1147 $failed_steps = 0;
1148
1149 try {
1150 foreach( $data[ 'funnel_meta' ] as $meta_key => $meta ) {
1151 if( !isset( $data[ 'funnel_meta' ][ $meta_key ][ 0 ] ) ) {
1152 continue;
1153 }
1154 if( in_array( $meta_key, Wpfnl_functions::VOLATILE_TRANSFER_META_KEYS, true ) ) {
1155 continue;
1156 }
1157 if( !in_array( $meta_key, $exclude_meta ) ) {
1158 $funnel->update_meta( $funnel_id, $meta_key, $this->safe_maybe_unserialize( $data[ 'funnel_meta' ][ $meta_key ][ 0 ] ) );
1159 }
1160 else {
1161 if( 'funnel_data' === $meta_key ) {
1162 $funnel_data = $this->safe_maybe_unserialize( $data[ 'funnel_meta' ][ $meta_key ][ 0 ] );
1163 }
1164
1165 if( '_funnel_data' === $meta_key ) {
1166 $funnel_data = $this->safe_maybe_unserialize( $data[ 'funnel_meta' ][ $meta_key ][ 0 ] );
1167 }
1168 }
1169 }
1170
1171 $total_steps = count( $data[ 'steps_data' ] );
1172
1173 foreach( $data[ 'steps_data' ] as $step_data ) {
1174 $response = $this->import_step( $funnel_id, $step_data );
1175
1176 if( is_wp_error( $response ) || !isset( $response[ 'id' ], $response[ 'title' ], $response[ 'type' ] ) ) {
1177 // Skip the step, keep the funnel: losing one step is better than losing the
1178 // funnel, and the result summary reports the funnel as partial.
1179 $failed_steps++;
1180 continue;
1181 }
1182
1183 $_new_step_ids[ $step_data[ 'id' ] ] = $response[ 'id' ];
1184 $_new_step_order[] = [
1185 'id' => $response[ 'id' ],
1186 'step_type' => $response[ 'type' ],
1187 'name' => $response[ 'title' ],
1188 ];
1189
1190 // Throttled: an unbounded write here would mean one update_option() per step,
1191 // which on a large funnel is pure DB churn.
1192 $done_steps++;
1193 $this->maybe_set_import_status( [ 'done_steps' => $done_steps ] );
1194 }
1195
1196 // A funnel whose every step failed is an empty shell. Leaving it behind would put a
1197 // broken entry in the funnel list that the user then has to find and delete.
1198 if( $total_steps > 0 && 0 === count( $_new_step_order ) ) {
1199 $this->rollback_funnel( $funnel_id, [] );
1200
1201 return new \WP_Error( 'all_steps_failed', __( 'None of the steps in this funnel could be imported.', 'wpfnl' ) );
1202 }
1203
1204 $this->update_funnel_data( $_new_step_ids, $funnel_id, $funnel_data );
1205 update_post_meta( $funnel_id, '_steps_order', $_new_step_order );
1206 update_post_meta( $funnel_id, '_steps', $_new_step_order );
1207
1208 $instance = new Wpfnl_functions();
1209 if( method_exists( $instance, 'generate_first_step' ) ) {
1210 Wpfnl_functions::generate_first_step( $funnel_id, [] );
1211 }
1212
1213 return [
1214 'funnel_id' => $funnel_id,
1215 'steps' => count( $_new_step_order ),
1216 'failed_steps' => $failed_steps,
1217 'first_step_id' => isset( $_new_step_order[ 0 ][ 'id' ] ) ? $_new_step_order[ 0 ][ 'id' ] : '',
1218 ];
1219 }
1220 catch( \Throwable $e ) {
1221 // The funnel post already exists at this point, so without this a crash mid-import
1222 // would leave a half-built funnel in the list. Clean it up, then let the caller
1223 // record the failure and move on to the next funnel.
1224 $this->rollback_funnel( $funnel_id, array_values( $_new_step_ids ) );
1225 throw $e;
1226 }
1227 }
1228
1229
1230 /**
1231 * Remove a funnel that could not be imported, along with any steps already created for it.
1232 *
1233 * @param int $funnel_id Funnel to delete.
1234 * @param array $step_ids Step ids created before the failure.
1235 *
1236 * @return void
1237 * @since 2.5.16
1238 */
1239 private function rollback_funnel( $funnel_id, $step_ids ) {
1240 foreach( (array) $step_ids as $step_id ) {
1241 if( $step_id ) {
1242 wp_delete_post( (int) $step_id, true );
1243 }
1244 }
1245
1246 if( $funnel_id ) {
1247 wp_delete_post( (int) $funnel_id, true );
1248 }
1249 }
1250
1251
1252 /**
1253 * Write the terminal status record and the fallback admin notice.
1254 *
1255 * @param string $run_id Run id this job was carrying.
1256 * @param bool $failed Whether the run ended on an error.
1257 * @param string $message Human-readable outcome.
1258 *
1259 * @return void
1260 * @since 2.5.16
1261 */
1262 private function finish_import( $run_id, $failed, $message ) {
1263 if( empty( $this->status ) ) {
1264 $this->status = self::get_import_status();
1265 }
1266
1267 if( $run_id && empty( $this->status[ 'run_id' ] ) ) {
1268 $this->status[ 'run_id' ] = $run_id;
1269 }
1270
1271 $this->set_import_status(
1272 [
1273 'state' => $failed ? 'failed' : 'completed',
1274 'label' => $message,
1275 'message' => $message,
1276 ]
1277 );
1278
1279 // Kept as the "you navigated away" channel: admin_notices is suppressed on the funnels
1280 // screen itself, so this only ever shows up somewhere else in wp-admin.
1281 update_option( 'wpfnl_import_notice', $message );
1282 }
1283
1284
1285 /**
1286 * Import step from json
1287 *
1288 * @param int $funnel_id
1289 * @param array $step_data
1290 *
1291 * @return array|\WP_Error `[ 'id', 'type', 'title' ]` on success. Returns a WP_Error rather
1292 * than a bare false since 2.5.16, so the caller can report *why* a
1293 * step was skipped in the import summary.
1294 * @since 1.7.5
1295 */
1296 public function import_step($funnel_id, $step_data){
1297 if (!is_array($step_data)) {
1298 return new \WP_Error( 'invalid_step_data', __( 'The step data was not readable.', 'wpfnl' ) );
1299 }
1300 $builder_type = $this->detect_step_builder($step_data);
1301 $previous_step_id = isset($step_data['id']) ? $step_data['id'] : '';
1302 $title = isset($step_data['title']) ? $step_data['title'] : '';
1303 $slug = isset($step_data['slug']) ? $step_data['slug'] : '';
1304 $page_template = isset($step_data['page_template']) ? $step_data['page_template'] : '';
1305 $post_content = isset($step_data['post_content']) ? $step_data['post_content'] : '';
1306 $step_type = isset($step_data['type']) ? $step_data['type'] : '';
1307 $step = new Wpfnl_Steps_Store_Data();
1308 $step_id = $step->create_step($funnel_id, $title, $step_type, $post_content, true);
1309
1310 if ($step_id) {
1311
1312 if ($slug) {
1313 wp_update_post(array(
1314 'ID' => $step_id,
1315 'post_name' => $slug
1316 ));
1317 }
1318
1319 $step->update_meta($step_id, '_funnel_id', $funnel_id);
1320 $step->update_meta($step_id, '_wp_page_template', $page_template);
1321 $exclude_meta = array_merge(
1322 array('_wpfnl_ab_testing_start_settings', '_funnel_id'),
1323 Wpfnl_functions::VOLATILE_TRANSFER_META_KEYS
1324 );
1325 if ( ! Wpfnl_functions::is_wpfnl_pro_activated() ) {
1326 $exclude_meta = array_merge( $exclude_meta, array(
1327 '_wpfnl_maybe_enable_condition',
1328 '_wpfnl_step_conditions',
1329 '_wpfnl_next_step_after_condition',
1330 ) );
1331 }
1332 $sql_query_arr = [];
1333 $is_enable_condition_meta_blank = false;
1334 $is_condition_exits = false;
1335 foreach ($step_data['meta'] as $meta_key => $meta) {
1336
1337 if (isset($meta[0])) {
1338 if (!in_array($meta_key, $exclude_meta)) {
1339 if ('_wpfnl_maybe_enable_condition' === $meta_key) {
1340 if ('' === $meta[0]) {
1341 $is_enable_condition_meta_blank = true;
1342 }
1343 }
1344
1345 if ('_wpfnl_step_conditions' === $meta_key) {
1346 if ($meta[0]) {
1347 $is_condition_exits = true;
1348 }
1349 }
1350
1351 $sql_data = [
1352 'post_id' => $step_id,
1353 'meta_key' => $meta_key,
1354 'meta_value' => $meta[0],
1355 ];
1356 $sql_query_arr[] = $sql_data;
1357 } elseif ('_wpfnl_ab_testing_start_settings' === $meta_key) {
1358
1359 $this->update_ab_testing_settings($funnel_id, $step_id, $meta[0], $step_data['ab_step_data']);
1360 }
1361 }
1362 }
1363 $this->update_meta_by_sql($sql_query_arr); // update meta
1364
1365 if ($is_enable_condition_meta_blank && $is_condition_exits) {
1366 update_post_meta($step_id, '_wpfnl_maybe_enable_condition', 'yes');
1367 }
1368
1369 $this->run_builder_post_import($step_id, $builder_type);
1370
1371 delete_post_meta($step_id, '_funnel_id');
1372 update_post_meta($step_id, '_funnel_id', $funnel_id);
1373
1374 return [
1375 'id' => $step_id,
1376 'type' => $step_type,
1377 'title' => $title,
1378 ];
1379 }
1380
1381 return new \WP_Error( 'step_create_failed', __( 'The step could not be created.', 'wpfnl' ) );
1382 }
1383
1384
1385 /**
1386 * Update post meta by raw SQL.
1387 *
1388 * Inserts post meta data into the WordPress database using raw SQL queries.
1389 *
1390 * @param array $sql_query_arr An array of SQL query values in the format (post_id, meta_key, meta_value).
1391 *
1392 * @since 1.8.10
1393 * @return boolean
1394 */
1395 public function update_meta_by_sql( $sql_query_arr ){
1396 if ( ! is_array( $sql_query_arr ) || empty( $sql_query_arr ) ) {
1397 return false;
1398 }
1399 global $wpdb;
1400 $insert_sql_query = "INSERT INTO $wpdb->postmeta (post_id, meta_key, meta_value) VALUES ";
1401 $value_placeholders = array();
1402 $query_args = array();
1403 foreach( $sql_query_arr as $query ) {
1404 if( isset($query['post_id'],$query['meta_key'],$query['meta_value']) ){
1405 $value_placeholders[] = '(%d, %s, %s)';
1406 $query_args[] = $query['post_id'];
1407 $query_args[] = $query['meta_key'];
1408 $query_args[] = $query['meta_value'];
1409 }
1410 }
1411 if( empty( $value_placeholders ) || empty( $query_args )){
1412 return false;
1413 }
1414 $insert_sql_query = rtrim( $insert_sql_query, ',' );
1415 $insert_sql_query .= ' ' . implode( ',', $value_placeholders );
1416 $prepared_query = $wpdb->prepare( $insert_sql_query, $query_args );
1417 $result = $wpdb->query( $prepared_query );
1418 return false !== $result;
1419 }
1420
1421
1422 /**
1423 * Update A/B testing settings for a funnel step.
1424 * This function updates the A/B testing settings for a funnel step with the provided parameters.
1425
1426 * @param int $funnel_id The ID of the funnel to which the step belongs.
1427 * @param int $step_id The ID of the step for which the A/B testing settings are being updated.
1428 * @param mixed $settings The new A/B testing settings for the step. This can be a serialized string or an array.
1429 * @param mixed $prev_settings The previous A/B testing settings for the step. This can be a serialized string or an array.
1430 *
1431 * @since 1.8.8
1432 * @return void
1433 */
1434 public function update_ab_testing_settings( $funnel_id, $step_id, $settings, $prev_settings ){
1435 $settings = $this->safe_maybe_unserialize($settings);
1436 $prev_settings = $this->safe_maybe_unserialize($prev_settings);
1437
1438 if( isset( $settings['variations'] ) ){
1439 $new_settings = [];
1440 $new_settings['isStart'] = 'no';
1441 $new_settings['startDate'] = isset($settings['startDate']) ?$settings['startDate'] : date( 'Y-m-d H:i:s' );
1442 $new_settings['endDate'] = isset($settings['endDate']) ?$settings['endDate'] : date( 'Y-m-d H:i:s' );
1443 $new_settings['variations'] = $this->update_ab_variations( $funnel_id, $step_id, $settings['variations'], $prev_settings );
1444 update_post_meta( $step_id, '_wpfnl_ab_testing_start_settings' , $new_settings );
1445 }
1446
1447 }
1448
1449
1450 /**
1451 * Update A/B testing variations for a funnel step.
1452 * This function updates the A/B testing variations for a funnel step with the provided parameters.
1453 *
1454 * @param int $funnel_id The ID of the funnel to which the step belongs.
1455 * @param int $step_id The ID of the step for which the A/B testing variations are being updated.
1456 * @param array $variations The new A/B testing variations for the step.
1457 * @param mixed $prev_settings The previous A/B testing settings for the step.
1458 *
1459 * @return array The updated A/B testing variations.
1460 * @since 1.8.8
1461 */
1462 public function update_ab_variations( $funnel_id, $step_id, $variations, $prev_settings ){
1463 if( count( $variations ) >= 2 ){
1464 $step = new Wpfnl_Steps_Store_Data();
1465 foreach( $variations as $variation_key=>$variation ){
1466
1467 if( !isset($variation['stepId'], $variation['variationType'] ) ){
1468 continue;
1469 }
1470
1471 $key = array_search($variation['stepId'], array_column($prev_settings, 'step_id'));
1472 if( false !== $key ){
1473
1474 if ( 'original' !== $variation['variationType'] && isset($prev_settings[$key]['meta']) ){
1475 $metas = $prev_settings[$key]['meta'];
1476 $exclude_meta = array_merge(
1477 array( '_wpfnl_ab_testing_start_settings','_wpfnl_automation_steps', '_parent_step_id' ),
1478 Wpfnl_functions::VOLATILE_TRANSFER_META_KEYS
1479 );
1480 $page_template = isset( $metas['page_template'] ) ? $metas['page_template'] : '';
1481 $title = isset( $metas['title'] ) ? $metas['title'] : '';
1482 $post_content = isset( $metas['post_content'] ) ? $metas['post_content'] : '';
1483 $step_type = isset( $metas['type'] ) ? $metas['type'] : '';
1484 if( $step ){
1485 $new_step_id = $step->create_step($funnel_id, $title, $step_type, $post_content, true);
1486 if( $new_step_id ){
1487 $step->update_meta($new_step_id, '_funnel_id', $funnel_id);
1488 $step->update_meta($new_step_id, '_wp_page_template', $page_template);
1489 $sql_query_arr = [];
1490
1491 foreach( $metas['meta'] as $meta_key=>$meta ){
1492 if( !in_array( $meta_key, $exclude_meta ) ){
1493 $sql_data = [
1494 'post_id' => $new_step_id,
1495 'meta_key' => $meta_key,
1496 'meta_value'=> $meta[0],
1497 ];
1498 $sql_query_arr[] = $sql_data;
1499 }
1500 }
1501 $this->update_meta_by_sql( $sql_query_arr ); // update meta
1502 // Variants carry their own builder content, so they need the
1503 // same post-import pass as a regular step.
1504 $this->run_builder_post_import( $new_step_id, $this->detect_step_builder( $metas ) );
1505 }
1506 }
1507
1508 }else{
1509 $new_step_id = $step_id;
1510 }
1511 update_post_meta( $new_step_id, '_parent_step_id', $step_id );
1512 $variations[$variation_key]['id'] = $new_step_id;
1513 $variations[$variation_key]['stepType'] = get_post_meta( $step_id, '_step_type', true );
1514 $step_edit_link = get_edit_post_link($new_step_id);
1515 // `ab_step_data[]` nests the full step payload under its `meta` key
1516 // (see Wpfnl_Export::export_ab_step_metas()), so detection reads that.
1517 $variation_step_data = isset( $prev_settings[$key]['meta'] ) ? $prev_settings[$key]['meta'] : [];
1518 if( 'elementor' === $this->detect_step_builder( $variation_step_data ) ){
1519 $step_edit_link = str_replace('&amp;','&',$step_edit_link);
1520 $step_edit_link = str_replace('edit','elementor',$step_edit_link);
1521 }
1522 $variations[$variation_key]['stepEditLink'] = $step_edit_link;
1523 $variations[$variation_key]['stepViewLink'] = get_the_permalink($new_step_id);
1524 $variations[$variation_key]['stepName'] = get_the_title($new_step_id);
1525 $variations[$variation_key]['stepId'] = $new_step_id;
1526 }
1527 }
1528 }else{
1529 $default_data = Wpfnl_Ab_Testing::get_default_data( $step_id );
1530 $variations = $default_data['variations'];
1531 }
1532 return $variations;
1533 }
1534
1535
1536
1537 /**
1538 * Update funnel data and steps
1539 *
1540 * @param Int $funnel_id
1541 * @param Array $step_data
1542 *
1543 * @return void
1544 * @since 1.7.5
1545 */
1546 public function update_funnel_data( $new_step_ids, $funnel_id, $funnel_data ){
1547 $funnel_identifier = array();
1548 if ($funnel_data) {
1549 $node_data = isset($funnel_data['drawflow']['Home']['data']) ? $funnel_data['drawflow']['Home']['data'] : [];
1550 foreach ($node_data as $node_key => $node_value) {
1551 if(isset($node_value['data']['step_id'])) {
1552 if ( isset($new_step_ids[$node_value['data']['step_id']]) ) {
1553 $post_edit_link = base64_encode(get_edit_post_link($new_step_ids[$node_value['data']['step_id']]));
1554 $post_view_link = base64_encode(get_post_permalink($new_step_ids[$node_value['data']['step_id']]));
1555 $funnel_data['drawflow']['Home']['data'][$node_key]['data']['step_id'] = $new_step_ids[$node_value['data']['step_id']];
1556 $funnel_data['drawflow']['Home']['data'][$node_key]['data']['step_edit_link'] = $post_edit_link;
1557 $funnel_data['drawflow']['Home']['data'][$node_key]['data']['step_view_link'] = $post_view_link;
1558 $funnel_data['drawflow']['Home']['data'][$node_key]['html'] = $node_value['data']['step_type'] . $new_step_ids[$node_value['data']['step_id']];
1559 $funnel_identifier[$node_value['id']] = $new_step_ids[$node_value['data']['step_id']];
1560 } else {
1561 if ($node_value['data']['step_type'] != 'conditional') {
1562 $funnel_identifier[$node_value['id']] = $node_value['data']['step_id'];
1563 } else {
1564 $funnel_identifier[$node_value['id']] = $node_value['data']['node_identifier'];
1565 }
1566 }
1567 }
1568 if ($node_value['data']['step_type'] == 'conditional') {
1569 $funnel_identifier[$node_value['id']] = $node_value['data']['node_identifier'];
1570 }
1571 }
1572 }
1573 update_post_meta($funnel_id, '_funnel_data', $funnel_data);
1574 if ($funnel_identifier) {
1575 $funnel_identifier_json = json_encode($funnel_identifier, JSON_UNESCAPED_SLASHES);
1576 update_post_meta($funnel_id, 'funnel_identifier', $funnel_identifier_json);
1577 }
1578 }
1579
1580 /**
1581 * Hide the import funnel notice based on nonce verification.
1582 *
1583 * This method is responsible for hiding the import funnel notice displayed to users. It receives a nonce value from the
1584 * submitted form, verifies the nonce using the 'wp_verify_nonce' function, and if the verification succeeds, it deletes the
1585 * 'wpfnl_import_notice' option to hide the notice. It then sends a JSON success response to the client and terminates the script
1586 * execution using 'wp_die'.
1587 *
1588 * @since 1.9.7
1589 */
1590 public function hide_import_funnel_notice() {
1591 $nonce = !empty( $_POST[ 'security' ] ) ? htmlspecialchars( trim( $_POST[ 'security' ] ) ) : null; // phpcs:ignore
1592
1593 if( wp_verify_nonce( $nonce, 'wpfnl-admin' ) ) {
1594 delete_option( 'wpfnl_import_notice' );
1595 }
1596 wp_send_json_success();
1597 wp_die();
1598 }
1599
1600 public function get_validation_data()
1601 {
1602 return [
1603 'logged_in' => true,
1604 'user_can' => 'wpf_manage_funnels',
1605 ];
1606 }
1607 }