'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__( '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__( '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' ) . '