'POST', 'permission_callback' => array( __CLASS__, 'allowed' ), 'callback' => array( __CLASS__, 'request' ), ) ); } public static function allowed( $request ) { $nonce = $request->get_header( 'X-WP-Nonce' ); if ( ! current_user_can( 'manage_options' ) ) { return false; } if ( ! is_string( $nonce ) ) { return false; } return (bool) wp_verify_nonce( sanitize_text_field( $nonce ), 'wp_rest' ); } private static function invalid( $message ) { return new WP_Error( 'kconv_blueprint_invalid', $message, array( 'status' => 400 ) ); } /** The only settings allowed to cross sites. Retention and delivery stay local. */ public static function settings_keys() { return array( 'submit_label', 'success_message', 'appearance_style', 'appearance_accent', 'appearance_density', 'appearance_font', 'appearance_width', 'appearance_radius', 'appearance_color', 'public_heading', 'public_description' ); } /** No third-party schema filter can add operational or private data to an export. */ public static function validate( $raw ) { if ( ! is_array( $raw ) || ! isset( $raw['title'], $raw['fields'] ) || ! is_string( $raw['title'] ) || strlen( $raw['title'] ) > 1000 || ! is_array( $raw['fields'] ) || count( $raw['fields'] ) > 50 ) { return self::invalid( __( 'Use a form title and 1–50 fields.', 'killerconversions' ) ); } if ( KCONV_Edition_Boundary::moved_features( $raw ) && ! KCONV_Edition_Boundary::processing_available() ) { return KCONV_Edition_Boundary::error(); } if ( ! class_exists( 'KCONV_Advanced_Logic' ) && ( ! empty( $raw['logic'] ) || ( ! empty( $raw['estimate'] ) && isset( $raw['estimate']['mode'] ) && 'advanced' === $raw['estimate']['mode'] ) ) ) { return self::invalid( __( 'This design requires matching Pro. Advanced meaning cannot be imported while Pro is inactive.', 'killerconversions' ) ); } $title = mb_substr( sanitize_text_field( $raw['title'] ), 0, 200 ); if ( '' === trim( $title ) ) { return self::invalid( __( 'Give this blueprint a form name.', 'killerconversions' ) ); } if ( array_key_exists( 'settings', $raw ) && ! is_array( $raw['settings'] ) ) { return self::invalid( __( 'Blueprint settings must be an object.', 'killerconversions' ) ); } $settings = array_intersect_key( isset( $raw['settings'] ) ? $raw['settings'] : array(), array_flip( self::settings_keys() ) ); foreach ( $settings as $value ) { if ( ! is_string( $value ) ) { return self::invalid( __( 'Blueprint appearance and completion values must be text.', 'killerconversions' ) ); } } $field_keys = array_flip( array( 'id', 'type', 'label', 'required', 'options', 'placeholder', 'help', 'width', 'default', 'condition', 'calculation', 'consent', 'validation', 'autocomplete', 'repeater' ) ); $fields = array(); foreach ( $raw['fields'] as $field ) { if ( ! is_array( $field ) ) { return self::invalid( __( 'Each blueprint question must be a field object.', 'killerconversions' ) ); } foreach ( array( 'id', 'type', 'label', 'placeholder', 'help', 'width', 'default' ) as $key ) { if ( array_key_exists( $key, $field ) && ! is_string( $field[ $key ] ) ) { return self::invalid( __( 'Question labels, identifiers and hints must be text.', 'killerconversions' ) ); } } if ( array_key_exists( 'required', $field ) && ! is_bool( $field['required'] ) ) { return self::invalid( __( 'Each required setting must be true or false.', 'killerconversions' ) ); } // Hidden defaults are public authoring content and can drive conditions. // Preserve them for faithful reuse; the UI asks owners to review sharing. $fields[] = array_intersect_key( $field, $field_keys ); } $schema = KCONV_Core::sanitize_schema( array_merge( array_intersect_key( $raw, array_flip( array( 'estimate', 'steps', 'logic' ) ) ), array( 'fields' => $fields, 'settings' => $settings ) ) ); if ( is_wp_error( $schema ) ) { return self::invalid( $schema->get_error_message() ); } $schema['fields'] = array_map( static function ( $field ) use ( $field_keys ) { return array_intersect_key( $field, $field_keys ); }, $schema['fields'] ); $schema['settings'] = array_intersect_key( $schema['settings'], array_flip( self::settings_keys() ) ); $pro = array_key_exists( 'pro', $raw ) ? $raw['pro'] : array(); if ( ! is_array( $pro ) ) { return self::invalid( __( 'Blueprint logic must contain rules and steps.', 'killerconversions' ) ); } foreach ( array( 'rules', 'steps' ) as $key ) { if ( array_key_exists( $key, $pro ) && ! is_array( $pro[ $key ] ) ) { return self::invalid( __( 'Rules and steps must be arrays.', 'killerconversions' ) ); } } $safe_pro = array_intersect_key( $pro, array_flip( array( 'rules', 'steps' ) ) ); if ( ! class_exists( 'KCONV_Pro' ) && ! empty( $safe_pro['rules'] ) ) { return self::invalid( __( 'This blueprint uses advanced Pro rules. Activate matching Pro before importing; its semantics will not be silently changed.', 'killerconversions' ) ); } $clean_pro = class_exists( 'KCONV_Pro' ) ? KCONV_Pro::sanitize( $safe_pro, array_merge( array( 'id' => 0 ), $schema ) ) : array( 'rules' => array(), 'steps' => $safe_pro['steps'] ?? array() ); if ( ! class_exists( 'KCONV_Pro' ) && ! empty( $clean_pro['steps'] ) ) { $step_check = KCONV_Steps::sanitize( $clean_pro['steps'], $schema['fields'] ); if ( is_wp_error( $step_check ) ) { return self::invalid( $step_check->get_error_message() ); } $clean_pro['steps'] = $step_check; } if ( is_wp_error( $clean_pro ) ) { return self::invalid( $clean_pro->get_error_message() ); } return array_merge( array_intersect_key( $schema, array_flip( array( 'estimate', 'steps', 'logic' ) ) ), array( 'title' => $title, 'fields' => array_values( $schema['fields'] ), 'settings' => $schema['settings'], 'pro' => array( 'rules' => $clean_pro['rules'], 'steps' => $clean_pro['steps'] ) ) ); } public static function request( $request ) { if ( ! self::allowed( $request ) ) { return new WP_Error( 'forbidden', __( 'Form administration required.', 'killerconversions' ), array( 'status' => 403 ) ); } // Extension operations hold their guard until all reads/writes and rollback complete. try { $allowed = apply_filters( 'kconv_blueprint_operation_allowed', true, $request ); if ( is_wp_error( $allowed ) ) { return $allowed; } if ( true !== $allowed ) { return new WP_Error( 'kconv_operation_unavailable', __( 'This operation is unavailable. No saved data has changed.', 'killerconversions' ), array( 'status' => 403 ) ); } return self::prepare_candidate( $request ); } finally { do_action( 'kconv_blueprint_operation_finished', $request ); } } private static function prepare_candidate( $request ) { $body = $request->get_body(); if ( ! is_string( $body ) || strlen( $body ) > self::MAX_BYTES ) { return self::invalid( __( 'Use a blueprint JSON file smaller than 256 KB.', 'killerconversions' ) ); } // Limit nesting before traversing untrusted imported objects. $raw = json_decode( $body, true, 16 ); if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $raw ) || empty( $raw['action'] ) || ! is_string( $raw['action'] ) ) { return self::invalid( __( 'This is not valid blueprint JSON.', 'killerconversions' ) ); } if ( 'template' === $raw['action'] ) { $templates = self::templates(); if ( ! isset( $raw['key'] ) || ! is_string( $raw['key'] ) || ! isset( $templates[ $raw['key'] ] ) ) { return self::invalid( __( 'Choose an available blueprint.', 'killerconversions' ) ); } $candidate = $templates[ $raw['key'] ]['form']; } elseif ( 'import' === $raw['action'] ) { $document = isset( $raw['document'] ) ? $raw['document'] : null; if ( ! is_array( $document ) || ! isset( $document['format'], $document['version'], $document['form'] ) || self::FORMAT !== $document['format'] || ! in_array( $document['version'], array( 1, 2, 3, 4, 5, 6 ), true ) ) { return self::invalid( __( 'Use a KillerConversions blueprint with format version 1, 2, 3, 4, 5 or 6.', 'killerconversions' ) ); } $candidate = $document['form']; foreach ( (array) ( $candidate['fields'] ?? array() ) as $field ) { if ( $document['version'] < 6 && ( 'repeater' === ( $field['type'] ?? '' ) || isset( $field['repeater'] ) ) ) { return self::invalid( __( 'Repeatable groups require blueprint format 6 and KillerConversions 0.11.0 or later.', 'killerconversions' ) ); } } if ( $document['version'] < 5 && array_intersect_key( (array) $candidate, array_flip( array( 'estimate', 'steps', 'logic' ) ) ) ) { return self::invalid( __( 'Itemised pricing and current workflow logic require blueprint format 5. Export again from version 0.10.0.', 'killerconversions' ) ); } // validate() checks the candidate shape and Pro requirements before reading pricing. if ( $document['version'] < 4 && is_array( $candidate ) && isset( $candidate['fields'] ) && is_array( $candidate['fields'] ) ) { foreach ( $candidate['fields'] as $field ) { if ( is_array( $field ) && ( array_key_exists( 'validation', $field ) || array_key_exists( 'autocomplete', $field ) ) ) { return self::invalid( __( 'Answer limits and autofill require blueprint format 4 and KillerConversions 0.9.0 or later. Export the design again from the updated plugin.', 'killerconversions' ) ); } } } if ( 4 === $document['version'] && isset( $document['minimum_version'] ) && '0.9.0' !== $document['minimum_version'] ) { return self::invalid( __( 'This blueprint requires a different plugin release. Update both plugins before importing it.', 'killerconversions' ) ); } } elseif ( 'validate' === $raw['action'] ) { $candidate = isset( $raw['candidate'] ) ? $raw['candidate'] : null; } else { return self::invalid( __( 'Choose a supported blueprint action.', 'killerconversions' ) ); } $candidate = self::validate( $candidate ); if ( is_wp_error( $candidate ) ) { return $candidate; } $format_version = self::FORMAT_VERSION; foreach ( $candidate['fields'] as $field ) { if ( isset( $field['validation'] ) || isset( $field['autocomplete'] ) ) { $format_version = 4; } if ( isset( $field['consent'] ) && $field['consent'] !== ( 'checkbox' === $field['type'] ) ) { $format_version = max( $format_version, 3 ); } if ( ! empty( $field['condition'] ) || 'calculated' === $field['type'] ) { $format_version = max( $format_version, 2 ); } } if ( array_intersect_key( $candidate, array_flip( array( 'estimate', 'steps', 'logic' ) ) ) ) { $format_version = 5; } if ( KCONV_Repeaters::has( $candidate ) ) { $format_version = 6; } $document = array( 'format' => self::FORMAT, 'version' => $format_version, 'form' => $candidate ); if ( 6 === $format_version ) { $document['minimum_version'] = '0.11.0'; } if ( 5 === $format_version ) { $document['minimum_version'] = '0.10.0'; } if ( 4 === $format_version ) { $document['minimum_version'] = '0.9.0'; } if ( strlen( wp_json_encode( $document, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) ) > self::MAX_BYTES - 256 ) { return self::invalid( __( 'This blueprint is too large to transfer. Shorten the field text or choice lists.', 'killerconversions' ) ); } $response = new WP_REST_Response( array( 'candidate' => $candidate, 'document' => $document ) ); $response->header( 'Cache-Control', 'no-store, private, max-age=0' ); $response->header( 'X-Content-Type-Options', 'nosniff' ); return $response; } public static function templates() { return apply_filters( 'kconv_blueprint_templates', array() ); } public static function editor( $form ) { if ( ! current_user_can( 'manage_options' ) ) { return; } wp_enqueue_style( 'kconv-blueprints', KCONV_URL . 'assets/blueprints.css', array( 'kconv-workspace' ), KCONV_VERSION ); wp_enqueue_script( 'kconv-blueprints', KCONV_URL . 'assets/blueprints.js', array( 'kconv-workspace', 'kconv-admin' , 'wp-i18n' ), KCONV_VERSION, true ); wp_set_script_translations( 'kconv-blueprints', 'killerconversions', KCONV_DIR . 'languages' ); wp_localize_script( 'kconv-blueprints', 'KCONV_Blueprints', array( 'endpoint' => rest_url( 'killerconversions/v1/admin/blueprints' ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'maxBytes' => self::MAX_BYTES, 'settingsKeys' => self::settings_keys() ) ); echo '
' . esc_html__( 'START WITH A COMPLETE FLOW', 'killerconversions' ) . '

' . esc_html__( 'Reusable form designs', 'killerconversions' ) . '

' . esc_html__( 'Review a complete starter, then replace the current design only when you choose. Operational settings stay under your control.', 'killerconversions' ) . '

'; foreach ( self::templates() as $key => $template ) { $candidate = $template['form']; /* translators: 1: number of questions, 2: number of steps; %d in the following plural is the number of conditions. */ echo '

' . esc_html( $template['name'] ) . '

' . esc_html( $template['description'] ) . '

' . esc_html( sprintf( __( '%1$d questions · %2$d steps', 'killerconversions' ), count( $candidate['fields'] ), count( $candidate['pro']['steps'] ) ) . ' · ' . sprintf( _n( '%d rule', '%d rules', count( $candidate['pro']['rules'] ), 'killerconversions' ), count( $candidate['pro']['rules'] ) ) ) . '
'; } echo '

' . esc_html__( 'For an estimate, review its price rows, extras and currency; legacy Pro calculated fields keep their formula controls. For an application, review the Pro visitor save-and-resume setting: selecting a design never enables it.', 'killerconversions' ) . '

' . esc_html__( 'Reuse across client sites', 'killerconversions' ) . '

' . esc_html__( 'Export the current design or import a blueprint JSON file. Entries, notification addresses, destination credentials and measurement settings are excluded. Labels, choices and hidden-field values are included: review the design before sharing.', 'killerconversions' ) . '

'; echo '
' . esc_html__( 'REVIEW BEFORE REPLACING', 'killerconversions' ) . '

' . esc_html__( 'This replaces the current name, questions, logic, steps and appearance in the editor. Your entries and local delivery, notification, measurement and retention settings stay in place. Review destination mappings and qualification rules after replacing fields. A published form changes only when you choose Publish updates.', 'killerconversions' ) . '

' . esc_html__( 'Review example prices, file limits and hidden-field values before reuse. Selecting a design does not enable visitor save and resume. Undo restores your previous design. No form is saved by importing.', 'killerconversions' ) . '

'; } }