PluginProbe
CartFlows – Funnel Builder & Checkout Plugin for WooCommerce / 3.3.0
CartFlows – Funnel Builder & Checkout Plugin for WooCommerce v3.3.0
3.3.0 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.1 trunk 1.0.4 1.1.0 1.1.0.1 1.1.1 1.1.10 1.1.11 1.1.12 1.1.13 1.1.14 1.1.15 1.1.16 1.1.17 1.1.18 1.1.19 1.1.2 All 162 releases
← All changes | admin-core/ajax/importer.php +357 -8 3.0.1 → 3.3.0 View file →
@@ -422,8 +422,12 @@
422 422 /* translators: %s: step ID */
423 423 wp_send_json_error( sprintf( __( 'Invalid step id %1$s.', 'cartflows' ), $new_step_id ) );
424 424 }
425 425
426 + // Insert the new step at the clicked edge position when invoked from a canvas connector.
427 + $this->handle_edge_insertion_reorder( $flow_id, $new_step_id );
428 + $this->update_offer_step_source_redirects( $flow_id, $new_step_id );
429 +
426 430 /**
427 431 * Redirect to the new flow edit screen
428 432 */
429 433 $response_data = array(
@@ -574,8 +578,9 @@
574 578 wp_send_json_error( $flow_id->get_error_message() );
575 579 }
576 580
577 581 $store_checkout = isset( $_POST['store_checkout'] ) ? sanitize_text_field( wp_unslash( $_POST['store_checkout'] ) ) : '';
582 + $instant_layout = isset( $_POST['instant_layout'] ) ? sanitize_text_field( wp_unslash( $_POST['instant_layout'] ) ) : '';
578 583
579 584 // If is store checkout update store_checkout options.
580 585 if ( 'true' === $store_checkout ) {
581 586 update_option( '_cartflows_store_checkout', $flow_id );
@@ -721,8 +726,10 @@
721 726 $is_error = AdminHelper::has_api_error( $response['data'] );
722 727
723 728 if ( $is_error['error'] ) {
724 729
730 + do_action( 'cartflows_generate_analytics_lead', $flow, false );
731 +
725 732 wp_send_json_error(
726 733 array(
727 734 'error_code' => $is_error['error_code'],
728 735 'call_to_action' => $is_error['call_to_action'],
@@ -742,11 +749,11 @@
742 749 $cta = '';
743 750 $btn = '';
744 751 if ( 'not-installed' === $cf_pro_status ) {
745 752 /* translators: %1$s: link html start, %2$s: link html end*/
746 - $btn = sprintf( __( 'CartFlows Pro Required! %1$sUpgrade to CartFlows Pro%2$s', 'cartflows' ), '<a target="_blank" href="https://cartflows.com/?utm_source=dashboard&utm_medium=free-cartflows&utm_campaign=go-pro">', '</a>' );
753 + $btn = sprintf( __( 'CartFlows Pro Required! %1$sUpgrade to CartFlows Pro%2$s', 'cartflows' ), '<a target="_blank" href="' . esc_url( \Cartflows_Helper::get_upgrade_to_pro_link( '', 'https://cartflows.com/' ) ) . '">', '</a>' );
747 754 /* translators: %1$s: link html start, %2$s: link html end*/
748 - $cta = sprintf( __( 'To import the premium flow %1$supgrade to CartFlows Pro%2$s.', 'cartflows' ), '<a target="_blank" href="https://cartflows.com/?utm_source=dashboard&utm_medium=free-cartflows&utm_campaign=go-pro">', '</a>' );
755 + $cta = sprintf( __( 'To import the premium flow %1$supgrade to CartFlows Pro%2$s.', 'cartflows' ), '<a target="_blank" href="' . esc_url( \Cartflows_Helper::get_upgrade_to_pro_link( '', 'https://cartflows.com/' ) ) . '">', '</a>' );
749 756 } elseif ( 'inactive' === $cf_pro_status ) {
750 757 /* translators: %1$s: link html start, %2$s: link html end*/
751 758 $btn = sprintf( __( 'Activate the CartFlows Pro to import the flow! %1$sActivate CartFlows Pro%2$s', 'cartflows' ), '<a target="_blank" href="' . admin_url( 'plugins.php?plugin_status=search&paged=1&s=CartFlows+Pro' ) . '">', '</a>' );
752 759 /* translators: %1$s: link html start, %2$s: link html end*/
@@ -792,8 +799,9 @@
792 799 // Insert the post into the database.
793 800 $new_flow_id = wp_insert_post( $new_flow_post );
794 801
795 802 if ( is_wp_error( $new_flow_id ) ) {
803 + do_action( 'cartflows_generate_analytics_lead', $flow, false );
796 804 wp_send_json_error( $new_flow_id->get_error_message() );
797 805 }
798 806
799 807 $store_checkout = isset( $_POST['store_checkout'] ) ? sanitize_text_field( wp_unslash( $_POST['store_checkout'] ) ) : '';
@@ -821,8 +829,9 @@
821 829 $steps = isset( $flow['steps'] ) ? $flow['steps'] : array();
822 830
823 831 // Return of no steps are found in the imported flow.
824 832 if ( empty( $steps ) ) {
833 + do_action( 'cartflows_generate_analytics_lead', $flow, false );
825 834 $response_data = array( 'message' => __( 'Steps not found.', 'cartflows' ) );
826 835 wp_send_json_error( $response_data );
827 836 }
828 837
@@ -879,8 +888,11 @@
879 888 AdminHelper::track_funnel_creation_method( $creation_method );
880 889
881 890 wcf()->logger->import_log( 'COMPLETE! Importing Flow' );
882 891
892 + // Report the completed template import to the CartFlows server.
893 + do_action( 'cartflows_generate_analytics_lead', $flow, true );
894 +
883 895 wp_send_json_success( $response_data );
884 896 }
885 897
886 898 /**
@@ -930,9 +942,9 @@
930 942 $response = \CartFlows_API::get_instance()->get_flow( $remote_flow_id );
931 943
932 944 if ( is_wp_error( $response['data'] ) ) {
933 945 /* translators: %1$s: html tag, %2$s: link html start %3$s: link html end */
934 - $btn = sprintf( __( 'Request timeout error. Please check if the firewall or any security plugin is blocking the outgoing HTTP/HTTPS requests to templates.cartflows.com or not. %1$sTo resolve this issue, please check this %2$sarticle%3$s.', 'cartflows' ), '<br><br>', '<a target="_blank" href="https://cartflows.com/docs/request-timeout-error-while-importing-the-flow-step-templates/?utm_source=dashboard&utm_medium=free-cartflows&utm_campaign=docs">', '</a>' );
946 + $btn = sprintf( __( 'Request timeout error. Please check if the firewall or any security plugin is blocking the outgoing HTTP/HTTPS requests to templates.cartflows.com or not. %1$sTo resolve this issue, please check this %2$sarticle%3$s.', 'cartflows' ), '<br><br>', '<a target="_blank" href="' . esc_url( \Cartflows_Helper::get_kb_doc_link( 'https://cartflows.com/docs/request-timeout-error-while-importing-the-flow-step-templates/' ) ) . '">', '</a>' );
935 947
936 948 wp_send_json_error(
937 949 array(
938 950 'message' => $response['data']->get_error_message(),
@@ -952,9 +964,9 @@
952 964 $msg = '';
953 965 $cta = '';
954 966 if ( 'not-installed' === $cf_pro_status ) {
955 967 /* translators: %1$s: link html start, %2$s: link html end*/
956 - $cta = sprintf( __( '%1$sUpgrade to CartFlows Pro.%2$s', 'cartflows' ), '<a target="_blanks" class="wcf-button wcf-primary-button" href="https://cartflows.com/?utm_source=dashboard&utm_medium=free-cartflows&utm_campaign=go-pro">', '</a>' );
968 + $cta = sprintf( __( '%1$sUpgrade to CartFlows Pro.%2$s', 'cartflows' ), '<a target="_blanks" class="wcf-button wcf-primary-button" href="' . esc_url( \Cartflows_Helper::get_upgrade_to_pro_link( '', 'https://cartflows.com/' ) ) . '">', '</a>' );
957 969 $msg = __( 'To import the premium step, please upgrade to CartFlows Pro', 'cartflows' );
958 970 } elseif ( 'inactive' === $cf_pro_status ) {
959 971 /* translators: %1$s: link html start, %2$s: link html end*/
960 972 $cta = sprintf( __( '%1$sActivate CartFlows Pro%2$s', 'cartflows' ), '<a target="_blank" class="wcf-button wcf-primary-button" href="' . admin_url( 'plugins.php?plugin_status=search&paged=1&s=CartFlows+Pro' ) . '">', '</a>' );
@@ -1056,9 +1068,9 @@
1056 1068 // Get single step Rest API response.
1057 1069 $response = \CartFlows_API::get_instance()->get_flow( $remote_flow_id );
1058 1070 if ( is_wp_error( $response['data'] ) ) {
1059 1071 /* translators: %1$s: html tag, %2$s: link html start %3$s: link html end */
1060 - $btn = sprintf( __( 'Request timeout error. Please check if the firewall or any security plugin is blocking the outgoing HTTP/HTTPS requests to templates.cartflows.com or not. %1$sTo resolve this issue, please check this %2$s article%3$s.', 'cartflows' ), '<br><br>', '<a target="_blank" href="https://cartflows.com/docs/request-timeout-error-while-importing-the-flow-step-templates/?utm_source=dashboard&utm_medium=free-cartflows&utm_campaign=docs">', '</a>' );
1072 + $btn = sprintf( __( 'Request timeout error. Please check if the firewall or any security plugin is blocking the outgoing HTTP/HTTPS requests to templates.cartflows.com or not. %1$sTo resolve this issue, please check this %2$s article%3$s.', 'cartflows' ), '<br><br>', '<a target="_blank" href="' . esc_url( \Cartflows_Helper::get_kb_doc_link( 'https://cartflows.com/docs/request-timeout-error-while-importing-the-flow-step-templates/' ) ) . '">', '</a>' );
1061 1073
1062 1074 wp_send_json_error(
1063 1075 array(
1064 1076 'message' => $response['data']->get_error_message(),
@@ -1077,9 +1089,9 @@
1077 1089
1078 1090 $cta = '';
1079 1091 if ( 'not-installed' === $cf_pro_status ) {
1080 1092 /* translators: %1$s: link html start, %2$s: link html end*/
1081 - $cta = sprintf( __( 'Upgrade to %1$sCartFlows Pro.%2$s', 'cartflows' ), '<a target="_blanks" href="https://cartflows.com/?utm_source=dashboard&utm_medium=free-cartflows&utm_campaign=go-pro">', '</a>' );
1093 + $cta = sprintf( __( 'Upgrade to %1$sCartFlows Pro.%2$s', 'cartflows' ), '<a target="_blanks" href="' . esc_url( \Cartflows_Helper::get_upgrade_to_pro_link( '', 'https://cartflows.com/' ) ) . '">', '</a>' );
1082 1094 } elseif ( 'inactive' === $cf_pro_status ) {
1083 1095 /* translators: %1$s: link html start, %2$s: link html end*/
1084 1096 $cta = sprintf( __( '%1$sActivate CartFlows Pro%2$s', 'cartflows' ), '<a target="_blank" href="' . admin_url( 'plugins.php?plugin_status=search&paged=1&s=CartFlows+Pro' ) . '">', '</a>' );
1085 1097 } elseif ( 'active' === $cf_pro_status ) {
@@ -1221,9 +1233,9 @@
1221 1233
1222 1234 $step_id = isset( $args['step']['id'] ) ? absint( $args['step']['id'] ) : 0;
1223 1235 $step_title = isset( $args['step']['title'] ) ? $args['step']['title'] : '';
1224 1236 $step_type = isset( $args['step']['type'] ) ? $args['step']['type'] : '';
1225 - $flow_id = isset( $args['flow']['id'] ) ? absint( $args['flow']['id'] ) : '';
1237 + $flow_id = isset( $args['flow']['id'] ) ? absint( $args['flow']['id'] ) : 0;
1226 1238 $is_store_checkout = isset( $args['is_store_checkout'] ) ? $args['is_store_checkout'] : '';
1227 1239
1228 1240 // create steps only for checkout and thankyou if store checkout.
1229 1241 // This logic will be removed once we have store checkout templates on server.
@@ -1238,8 +1250,14 @@
1238 1250 /* translators: %s: step ID */
1239 1251 wp_send_json_error( sprintf( __( 'Invalid step id %1$s or post id %2$s.', 'cartflows' ), $step_id, $new_step_id ) );
1240 1252 }
1241 1253
1254 + // Handle edge insertion - reorder flow steps when inserting on an edge.
1255 + $this->handle_edge_insertion_reorder( $flow_id, $new_step_id );
1256 +
1257 + // Handle offer edge source update - update source offer step's redirect to point to new step.
1258 + $this->update_offer_step_source_redirects( $flow_id, $new_step_id );
1259 +
1242 1260 wcf()->logger->import_log( 'Remote Step ' . $step_id . ' for local flow "' . get_the_title( $new_step_id ) . '" [' . $new_step_id . ']' );
1243 1261
1244 1262 // Get single step Rest API response.
1245 1263 $response = \CartFlows_API::get_instance()->get_template( $step_id );
@@ -1289,8 +1307,11 @@
1289 1307
1290 1308 // Import Post Meta.
1291 1309 $this->import_post_meta( $new_step_id, $response );
1292 1310
1311 + // Handle user-selected offer redirect settings for upsell/downsell steps.
1312 + $this->update_user_selected_offer_redirects( $flow_id, $new_step_id, $step_type );
1313 +
1293 1314 if ( 'checkout' === $step_type ) {
1294 1315
1295 1316 $posted_data = array(
1296 1317 'primary_color' => isset( $_POST['primary_color'] ) ? sanitize_text_field( wp_unslash( $_POST['primary_color'] ) ) : '',
@@ -1340,9 +1361,20 @@
1340 1361 $meta_value = isset( $meta_value[0] ) ? $meta_value[0] : '';
1341 1362
1342 1363 if ( $meta_value ) {
1343 1364
1344 - if ( is_serialized( $meta_value, true ) ) {
1365 + // Beaver Builder layout keys are handled separately from the generic security
1366 + // path below. BB stores rows/columns/modules as serialized arrays of stdClass
1367 + // objects. We allow only stdClass — no other classes are expected or permitted.
1368 + // Passing the raw serialized string directly to update_post_meta would cause
1369 + // WordPress to double-serialize it, breaking BB's get_post_meta readback.
1370 + $bb_meta_keys = array( '_fl_builder_data', '_fl_builder_draft', '_fl_builder_data_settings' );
1371 + if ( in_array( $meta_key, $bb_meta_keys, true ) ) {
1372 + $raw_data = $this->unserialize_bb_meta( $meta_key, $meta_value );
1373 + if ( false === $raw_data ) {
1374 + continue;
1375 + }
1376 + } elseif ( is_serialized( $meta_value, true ) ) {
1345 1377 // Security: Using unserialize with allowed_classes=>false to prevent object injection.
1346 1378 $raw_data = unserialize( stripslashes( $meta_value ), array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, PHPCompatibility.FunctionUse.NewFunctionParameters.unserialize_optionsFound
1347 1379 // Drop malicious payloads completely to prevent fatal errors.
1348 1380 // Security: Block serialized meta that contains nested PHP objects.
@@ -1642,8 +1674,40 @@
1642 1674 wcf()->logger->import_log( 'End: ' . __CLASS__ . ' :: ' . __FUNCTION__ );
1643 1675 }
1644 1676
1645 1677 /**
1678 + * Deserialize a Beaver Builder layout meta value.
1679 + *
1680 + * BB layout keys (_fl_builder_data, _fl_builder_draft, _fl_builder_data_settings)
1681 + * store arrays of stdClass nodes. Only stdClass is allowed during unserialize —
1682 + * no other classes are expected in BB layout data and none should be permitted.
1683 + *
1684 + * Returns false if the value is serialized but cannot be unserialized, so the
1685 + * caller can skip the key rather than storing corrupt data silently.
1686 + *
1687 + * @since 3.0.2
1688 + *
1689 + * @param string $meta_key The meta key being processed (used for error logging).
1690 + * @param mixed $meta_value The raw meta value from the template API response.
1691 + * @return mixed Unserialized value, original value if not serialized, or false on failure.
1692 + */
1693 + private function unserialize_bb_meta( $meta_key, $meta_value ) {
1694 + if ( ! is_serialized( $meta_value ) ) {
1695 + return $meta_value;
1696 + }
1697 +
1698 + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, PHPCompatibility.FunctionUse.NewFunctionParameters.unserialize_optionsFound -- BB layout data uses only stdClass; no arbitrary classes are permitted.
1699 + $result = unserialize( $meta_value, array( 'allowed_classes' => array( 'stdClass' ) ) );
1700 +
1701 + if ( false === $result ) {
1702 + wcf()->logger->import_log( 'Failed to unserialize BB meta key: ' . $meta_key );
1703 + return false;
1704 + }
1705 +
1706 + return $result;
1707 + }
1708 +
1709 + /**
1646 1710 * Detect whether the given value contains objects at any depth.
1647 1711 *
1648 1712 * This is used as a hard security guard to prevent storing
1649 1713 * unserialized payloads that contain PHP objects (including
@@ -1670,6 +1734,291 @@
1670 1734 }
1671 1735 }
1672 1736
1673 1737 return false;
1738 + }
1739 +
1740 + /**
1741 + * Handle edge insertion reorder.
1742 + *
1743 + * Reorders flow steps when a new step is inserted on an edge between two existing steps.
1744 + * Handles both START node edges and regular step edges.
1745 + *
1746 + * @since 3.1.0
1747 + * @param int $flow_id The flow ID.
1748 + * @param int $new_step_id The newly created step ID.
1749 + * @return void
1750 + */
1751 + private function handle_edge_insertion_reorder( $flow_id, $new_step_id ) {
1752 +
1753 + // Per-call IDOR guard — re-verify the current user can edit this flow.
1754 + if ( ! $this->user_can_edit_flow( $flow_id ) ) {
1755 + return;
1756 + }
1757 +
1758 + $is_start_edge = isset( $_POST['is_start_edge'] ) && 'true' === $_POST['is_start_edge']; // phpcs:ignore WordPress.Security.NonceVerification.Missing
1759 + $edge_source_step_id = isset( $_POST['edge_source_step_id'] ) ? absint( $_POST['edge_source_step_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
1760 + $edge_target_step_id = isset( $_POST['edge_target_step_id'] ) ? absint( $_POST['edge_target_step_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
1761 +
1762 + // Handle edge insertion if we have a target step ID (either from START node or regular step).
1763 + if ( ! $edge_target_step_id || ( ! $is_start_edge && ! $edge_source_step_id ) ) {
1764 + return;
1765 + }
1766 +
1767 + // IDOR guard — both edge endpoints must belong to this flow.
1768 + // The START node is virtual and has no step ID, so skip its check.
1769 + if ( ! $is_start_edge && ! $this->is_step_in_flow( $edge_source_step_id, $flow_id ) ) {
1770 + return;
1771 + }
1772 + if ( ! $this->is_step_in_flow( $edge_target_step_id, $flow_id ) ) {
1773 + return;
1774 + }
1775 +
1776 + // Get current flow steps.
1777 + $flow_steps = get_post_meta( (int) $flow_id, 'wcf-steps', true );
1778 +
1779 + if ( ! is_array( $flow_steps ) || empty( $flow_steps ) ) {
1780 + return;
1781 + }
1782 +
1783 + // Find indices of source and target steps.
1784 + $source_index = -1; // -1 for START node means insert at position 0.
1785 + $target_index = -1;
1786 + $new_step_index = -1;
1787 +
1788 + foreach ( $flow_steps as $index => $step_data ) {
1789 + if ( isset( $step_data['id'] ) ) {
1790 + if ( ! $is_start_edge && $step_data['id'] === $edge_source_step_id ) {
1791 + $source_index = $index;
1792 + }
1793 + if ( $step_data['id'] === $edge_target_step_id ) {
1794 + $target_index = $index;
1795 + }
1796 + if ( $step_data['id'] === $new_step_id ) {
1797 + $new_step_index = $index;
1798 + }
1799 + }
1800 + }
1801 +
1802 + // Determine if we can proceed with reordering.
1803 + $can_reorder = -1 !== $target_index && -1 !== $new_step_index;
1804 + if ( ! $is_start_edge ) {
1805 + $can_reorder = $can_reorder && -1 !== $source_index;
1806 + }
1807 +
1808 + if ( ! $can_reorder ) {
1809 + return;
1810 + }
1811 +
1812 + // Remove the new step from its current position (at the end).
1813 + $new_step_data = $flow_steps[ $new_step_index ];
1814 + array_splice( $flow_steps, $new_step_index, 1 );
1815 +
1816 + // Recalculate indices after removal (if new step was before them).
1817 + if ( ! $is_start_edge && $new_step_index < $source_index ) {
1818 + --$source_index;
1819 + }
1820 + if ( $new_step_index < $target_index ) {
1821 + --$target_index;
1822 + }
1823 +
1824 + // Insert the new step at the correct position.
1825 + if ( $is_start_edge ) {
1826 + // START node: insert at position 0 (beginning of flow).
1827 + $insert_position = 0;
1828 + } else {
1829 + // Regular step: insert right after the source step.
1830 + $insert_position = $source_index + 1;
1831 + }
1832 +
1833 + array_splice( $flow_steps, (int) $insert_position, 0, array( $new_step_data ) );
1834 +
1835 + // Update the flow steps meta.
1836 + update_post_meta( (int) $flow_id, 'wcf-steps', $flow_steps );
1837 + }
1838 +
1839 + /**
1840 + * Update offer step source redirects.
1841 + *
1842 + * When inserting on an Accept/Reject edge, updates the source offer step's
1843 + * redirect to point to the new step.
1844 + *
1845 + * @since 3.1.0
1846 + * @param int $flow_id The flow ID.
1847 + * @param int $new_step_id The newly created step ID.
1848 + * @return void
1849 + */
1850 + private function update_offer_step_source_redirects( $flow_id, $new_step_id ) {
1851 +
1852 + // Per-call IDOR guard — re-verify the current user can edit this flow.
1853 + if ( ! $this->user_can_edit_flow( $flow_id ) ) {
1854 + return;
1855 + }
1856 +
1857 + $edge_source_step_id = isset( $_POST['edge_source_step_id'] ) ? absint( $_POST['edge_source_step_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
1858 + $edge_source_handle = isset( $_POST['edge_source_handle'] ) ? sanitize_text_field( wp_unslash( $_POST['edge_source_handle'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing
1859 +
1860 + if ( $edge_source_step_id <= 0 || empty( $edge_source_handle ) ) {
1861 + return;
1862 + }
1863 +
1864 + // IDOR guard — both source and target steps must belong to this flow.
1865 + if ( ! $this->is_step_in_flow( $edge_source_step_id, $flow_id ) ) {
1866 + return;
1867 + }
1868 + if ( ! $this->is_step_in_flow( $new_step_id, $flow_id ) ) {
1869 + return;
1870 + }
1871 +
1872 + $source_step_type = get_post_meta( $edge_source_step_id, 'wcf-step-type', true );
1873 +
1874 + // Only update if source is an offer step (upsell/downsell).
1875 + if ( ! in_array( $source_step_type, array( 'upsell', 'downsell' ), true ) ) {
1876 + return;
1877 + }
1878 +
1879 + $source_meta_updated = false;
1880 +
1881 + // Update the appropriate post meta based on handle.
1882 + if ( 'a' === $edge_source_handle ) {
1883 + // Accept edge - update wcf-yes-next-step.
1884 + update_post_meta( $edge_source_step_id, 'wcf-yes-next-step', $new_step_id );
1885 + $source_meta_updated = true;
1886 + } elseif ( 'b' === $edge_source_handle ) {
1887 + // Reject edge - update wcf-no-next-step.
1888 + update_post_meta( $edge_source_step_id, 'wcf-no-next-step', $new_step_id );
1889 + $source_meta_updated = true;
1890 + }
1891 +
1892 + // Also update the flow_steps array to keep it in sync.
1893 + if ( $source_meta_updated ) {
1894 + $this->sync_offer_redirects_in_flow_steps( $flow_id, $edge_source_step_id, $edge_source_handle, $new_step_id );
1895 + }
1896 + }
1897 +
1898 + /**
1899 + * Sync offer redirects in flow_steps array.
1900 + *
1901 + * Updates the flow_steps array to keep it in sync with post meta.
1902 + * Handles both control steps and A/B test variations.
1903 + *
1904 + * @since 3.1.0
1905 + * @param int $flow_id The flow ID.
1906 + * @param int $source_step_id The source offer step ID.
1907 + * @param string $handle The edge handle ('a' for accept, 'b' for reject).
1908 + * @param int $target_step_id The target step ID to redirect to.
1909 + * @return void
1910 + */
1911 + private function sync_offer_redirects_in_flow_steps( $flow_id, $source_step_id, $handle, $target_step_id ) {
1912 + $updated_flow_steps = get_post_meta( (int) $flow_id, 'wcf-steps', true );
1913 +
1914 + if ( ! is_array( $updated_flow_steps ) ) {
1915 + return;
1916 + }
1917 +
1918 + $flow_steps_updated = false;
1919 +
1920 + foreach ( $updated_flow_steps as $idx => $step_data ) {
1921 + // Check if this is the control step (direct match).
1922 + if ( isset( $step_data['id'] ) && (int) $step_data['id'] === (int) $source_step_id ) {
1923 + if ( 'a' === $handle ) {
1924 + $updated_flow_steps[ $idx ]['offer_yes_step_id'] = $target_step_id;
1925 + } elseif ( 'b' === $handle ) {
1926 + $updated_flow_steps[ $idx ]['offer_no_step_id'] = $target_step_id;
1927 + }
1928 + $flow_steps_updated = true;
1929 + break;
1930 + }
1931 +
1932 + // Check if the source is an A/B test variation within this step.
1933 + if ( isset( $step_data['ab-test-variations'] ) && is_array( $step_data['ab-test-variations'] ) ) {
1934 + foreach ( $step_data['ab-test-variations'] as $var_idx => $variation ) {
1935 + if ( isset( $variation['id'] ) && (int) $variation['id'] === (int) $source_step_id ) {
1936 + if ( 'a' === $handle ) {
1937 + $updated_flow_steps[ $idx ]['ab-test-variations'][ $var_idx ]['offer_yes_step_id'] = $target_step_id;
1938 + } elseif ( 'b' === $handle ) {
1939 + $updated_flow_steps[ $idx ]['ab-test-variations'][ $var_idx ]['offer_no_step_id'] = $target_step_id;
1940 + }
1941 + $flow_steps_updated = true;
1942 + break 2; // Break out of both loops.
1943 + }
1944 + }
1945 + }
1946 + }
1947 +
1948 + if ( $flow_steps_updated ) {
1949 + update_post_meta( (int) $flow_id, 'wcf-steps', $updated_flow_steps );
1950 + }
1951 + }
1952 +
1953 + /**
1954 + * Update user-selected offer redirects.
1955 + *
1956 + * Handles user-selected offer redirect settings for upsell/downsell steps
1957 + * when the user explicitly selects Accept/Reject targets via the UI.
1958 + *
1959 + * @since 3.1.0
1960 + * @param int $flow_id The flow ID.
1961 + * @param int $new_step_id The newly created step ID.
1962 + * @param string $step_type The step type.
1963 + * @return void
1964 + */
1965 + private function update_user_selected_offer_redirects( $flow_id, $new_step_id, $step_type ) {
1966 +
1967 + // Per-call IDOR guard — re-verify the current user can edit this flow.
1968 + if ( ! $this->user_can_edit_flow( $flow_id ) ) {
1969 + return;
1970 + }
1971 +
1972 + // Only process for offer steps.
1973 + if ( ! in_array( $step_type, array( 'upsell', 'downsell' ), true ) ) {
1974 + return;
1975 + }
1976 +
1977 + // IDOR guard — the new step itself must belong to this flow.
1978 + if ( ! $this->is_step_in_flow( $new_step_id, $flow_id ) ) {
1979 + return;
1980 + }
1981 +
1982 + $user_offer_yes_step_id = isset( $_POST['offer_yes_step_id'] ) ? absint( $_POST['offer_yes_step_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
1983 + $user_offer_no_step_id = isset( $_POST['offer_no_step_id'] ) ? absint( $_POST['offer_no_step_id'] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing
1984 +
1985 + // IDOR guard — redirect targets must belong to this flow if provided.
1986 + if ( $user_offer_yes_step_id && ! $this->is_step_in_flow( $user_offer_yes_step_id, $flow_id ) ) {
1987 + $user_offer_yes_step_id = 0;
1988 + }
1989 + if ( $user_offer_no_step_id && ! $this->is_step_in_flow( $user_offer_no_step_id, $flow_id ) ) {
1990 + $user_offer_no_step_id = 0;
1991 + }
1992 +
1993 + // Return if user didn't select any custom redirects.
1994 + if ( ! $user_offer_yes_step_id && ! $user_offer_no_step_id ) {
1995 + return;
1996 + }
1997 +
1998 + $current_flow_steps = get_post_meta( (int) $flow_id, 'wcf-steps', true );
1999 +
2000 + if ( ! is_array( $current_flow_steps ) ) {
2001 + return;
2002 + }
2003 +
2004 + foreach ( $current_flow_steps as $idx => $step_data ) {
2005 + if ( isset( $step_data['id'] ) && $step_data['id'] === $new_step_id ) {
2006 + // Update offer_yes_step_id if user selected one.
2007 + if ( $user_offer_yes_step_id ) {
2008 + $current_flow_steps[ $idx ]['offer_yes_step_id'] = $user_offer_yes_step_id;
2009 + update_post_meta( $new_step_id, 'wcf-yes-next-step', $user_offer_yes_step_id );
2010 + }
2011 +
2012 + // Update offer_no_step_id if user selected one.
2013 + if ( $user_offer_no_step_id ) {
2014 + $current_flow_steps[ $idx ]['offer_no_step_id'] = $user_offer_no_step_id;
2015 + update_post_meta( $new_step_id, 'wcf-no-next-step', $user_offer_no_step_id );
2016 + }
2017 + break;
2018 + }
2019 + }
2020 +
2021 + // Save the updated flow steps.
2022 + update_post_meta( (int) $flow_id, 'wcf-steps', $current_flow_steps );
1674 2023 }
1675 2024 }