add_cap( $cap ); } } add_role( 'kconv_lead_manager', __( 'Lead manager', 'killerconversions' ), array( 'read' => true, 'kconv_view_entries' => true, 'kconv_export_entries' => true, 'kconv_review_entries' => true, 'kconv_manage_delivery' => true ) ); update_option( 'kconv_roles_version', '1', false ); } public static function value_text( $value ) { if ( class_exists( 'KCONV_Repeaters' ) && KCONV_Repeaters::is_value( $value ) ) { return KCONV_Repeaters::text( $value ); } if ( is_array( $value ) && isset( $value['file_id'], $value['name'], $value['type'], $value['bytes'] ) ) { return sanitize_file_name( $value['name'] ) . ' (' . sanitize_text_field( $value['type'] ) . ', ' . absint( $value['bytes'] ) . ' bytes)'; } return is_array( $value ) ? implode( ', ', array_map( 'strval', array_filter( $value, 'is_scalar' ) ) ) : (string) $value; } public static function valid_date( $value ) { if ( ! is_string( $value ) || ! preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/D', $value, $parts ) ) { return false; } return checkdate( (int) $parts[2], (int) $parts[3], (int) $parts[1] ); } public static function maybe_upgrade() { if ( get_option( 'kconv_db_version' ) !== KCONV_VERSION ) { self::activate(); } self::ensure_cleanup(); } public static function ensure_cleanup() { if ( ! wp_next_scheduled( 'kconv_cleanup' ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', 'kconv_cleanup' ); } } public static function table( $suffix ) { global $wpdb; return $wpdb->prefix . 'kconv_' . sanitize_key( $suffix ); } public static function activate() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $charset = $wpdb->get_charset_collate(); $entries = self::table( 'entries' ); $subjects = self::table( 'subjects' ); $limits = self::table( 'limits' ); dbDelta( "CREATE TABLE $entries ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, form_id bigint(20) unsigned NOT NULL, created_at datetime NOT NULL, expires_at datetime NOT NULL, payload longtext NOT NULL, schema_snapshot longtext NULL, mail_status varchar(20) NOT NULL DEFAULT 'disabled', is_read tinyint(1) NOT NULL DEFAULT 0, starred tinyint(1) NOT NULL DEFAULT 0, assigned_to bigint(20) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY form_created (form_id,created_at), KEY assigned_read (assigned_to,is_read), KEY expires_at (expires_at) ) ENGINE=InnoDB $charset;" ); dbDelta( "CREATE TABLE $subjects ( entry_id bigint(20) unsigned NOT NULL, email varchar(191) NOT NULL, PRIMARY KEY (entry_id,email), KEY email (email) ) ENGINE=InnoDB $charset;" ); dbDelta( "CREATE TABLE $limits ( bucket char(64) NOT NULL, hits int unsigned NOT NULL DEFAULT 1, expires bigint unsigned NOT NULL, PRIMARY KEY (bucket), KEY expires (expires) ) ENGINE=InnoDB $charset;" ); KCONV_Receipts::install(); $required = array( 'entries' => array( 'id', 'form_id', 'created_at', 'expires_at', 'payload', 'schema_snapshot', 'mail_status', 'is_read', 'starred', 'assigned_to' ), 'subjects' => array( 'entry_id', 'email' ), 'limits' => array( 'bucket', 'hits', 'expires' ), 'receipts' => array( 'request_hash', 'payload_hash', 'form_id', 'entry_id', 'message' ) ); foreach ( $required as $suffix => $columns ) { $table = self::table( $suffix ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $actual = $wpdb->get_col( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ) ); if ( $wpdb->last_error || array_diff( $columns, (array) $actual ) ) { update_option( 'kconv_schema_problem', true, false ); return; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $engine = $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ); if ( 'innodb' !== strtolower( (string) $engine ) ) { update_option( 'kconv_schema_problem', true, false ); return; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $indexes = $wpdb->get_results( $wpdb->prepare( 'SHOW INDEX FROM %i', $table ), ARRAY_A ); $keys = array(); foreach ( (array) $indexes as $index ) { $keys[ $index['Key_name'] ][] = $index['Column_name']; } $expected = array( 'entries' => array( 'PRIMARY' => array( 'id' ), 'form_created' => array( 'form_id', 'created_at' ), 'assigned_read' => array( 'assigned_to', 'is_read' ), 'expires_at' => array( 'expires_at' ) ), 'subjects' => array( 'PRIMARY' => array( 'entry_id', 'email' ), 'email' => array( 'email' ) ), 'limits' => array( 'PRIMARY' => array( 'bucket' ), 'expires' => array( 'expires' ) ), 'receipts' => array( 'PRIMARY' => array( 'request_hash' ), 'entry_id' => array( 'entry_id' ) ) ); if ( $wpdb->last_error ) { update_option( 'kconv_schema_problem', true, false ); return; } foreach ( $expected[ $suffix ] as $name => $indexed ) { if ( ! isset( $keys[ $name ] ) || $keys[ $name ] !== $indexed ) { update_option( 'kconv_schema_problem', true, false ); return; } } } delete_option( 'kconv_schema_problem' ); update_option( 'kconv_db_version', KCONV_VERSION, false ); self::ensure_cleanup(); } public static function deactivate() { wp_clear_scheduled_hook( 'kconv_cleanup' ); wp_clear_scheduled_hook( 'kconv_cleanup_continue' ); } public static function register() { // Core APIs must enforce the same form-administration boundary as the builder. register_post_type( 'kconv_form', array( 'public' => false, 'show_ui' => false, 'show_in_rest' => false, 'supports' => array( 'title' ), 'rewrite' => false, 'query_var' => false, 'can_export' => false, 'map_meta_cap' => false, 'capabilities' => array( 'edit_post' => 'manage_options', 'read_post' => 'manage_options', 'delete_post' => 'manage_options', 'edit_posts' => 'manage_options', 'edit_others_posts' => 'manage_options', 'delete_posts' => 'manage_options', 'publish_posts' => 'manage_options', 'read_private_posts' => 'manage_options', 'create_posts' => 'manage_options', ) ) ); wp_register_style( 'kconv-forms', KCONV_URL . 'assets/forms.css', array(), KCONV_VERSION ); wp_register_script( 'kconv-forms', KCONV_URL . 'assets/forms.js', array('wp-i18n'), KCONV_VERSION, true ); wp_set_script_translations( 'kconv-forms', 'killerconversions', KCONV_DIR . 'languages' ); wp_register_script( 'kconv-block', KCONV_URL . 'blocks/form/editor.js', array( 'wp-blocks', 'wp-element', 'wp-components', 'wp-block-editor', 'wp-api-fetch', 'wp-server-side-render', 'wp-i18n' ), KCONV_VERSION, true ); wp_set_script_translations( 'kconv-block', 'killerconversions', KCONV_DIR . 'languages' ); // This controls the editor's presentation only; the REST endpoint checks authority itself. wp_add_inline_script( 'kconv-block', 'window.kconvBlockSettings = ' . wp_json_encode( array( 'canManageForms' => current_user_can( 'manage_options' ) ) ) . ';', 'before' ); register_block_type( KCONV_DIR . 'blocks/form', array( 'render_callback' => function ( $attributes ) { return self::render( isset( $attributes['formId'] ) ? absint( $attributes['formId'] ) : 0 ); } ) ); add_shortcode( 'killerconversions', function ( $atts ) { $atts = shortcode_atts( array( 'id' => 0 ), $atts, 'killerconversions' ); return self::render( absint( $atts['id'] ) ); } ); } public static function defaults() { return array_merge( array( 'submit_label' => __( 'Send message', 'killerconversions' ), 'success_message' => __( 'Thank you. Your message has been received.', 'killerconversions' ), 'notify_email' => '', 'retention_days' => 90 ), self::appearance_settings( array() ) ); } /** Fixed, local style choices only; never interpolate submitted CSS or classes. */ public static function appearance_settings( $input ) { $choices = array( 'appearance_style' => array( 'classic', 'studio', 'minimal' ), 'appearance_accent' => array( 'forest', 'indigo', 'ink' ), 'appearance_density' => array( 'comfortable', 'compact' ), 'appearance_font' => array( 'inherit', 'system', 'serif' ), 'appearance_width' => array( 'standard', 'wide', 'full' ), 'appearance_radius' => array( 'default', 'square', 'rounded' ), ); $clean = array(); foreach ( $choices as $key => $allowed ) { $clean[ $key ] = is_array( $input ) && isset( $input[ $key ] ) && is_string( $input[ $key ] ) && in_array( $input[ $key ], $allowed, true ) ? $input[ $key ] : $allowed[0]; } $clean['appearance_color'] = is_array( $input ) && isset( $input['appearance_color'] ) && is_string( $input['appearance_color'] ) && preg_match( '/^#[a-f0-9]{6}$/Di', $input['appearance_color'] ) ? strtolower( $input['appearance_color'] ) : ''; foreach ( array( 'public_heading' => 160, 'public_description' => 600 ) as $key => $limit ) { $clean[ $key ] = is_array( $input ) && isset( $input[ $key ] ) && is_string( $input[ $key ] ) ? mb_substr( sanitize_text_field( $input[ $key ] ), 0, $limit ) : ''; } return $clean; } /** Black or white always provides at least 4.5:1 contrast against an opaque sRGB color. */ public static function accent_foreground( $color ) { if ( ! is_string( $color ) || ! preg_match( '/^#[a-f0-9]{6}$/Di', $color ) ) { return '#ffffff'; } $luminance = 0; foreach ( array( 0.2126, 0.7152, 0.0722 ) as $index => $weight ) { $channel = hexdec( substr( $color, 1 + $index * 2, 2 ) ) / 255; $linear = $channel <= 0.04045 ? $channel / 12.92 : pow( ( $channel + 0.055 ) / 1.055, 2.4 ); $luminance += $linear * $weight; } return ( $luminance + 0.05 ) / 0.05 >= 1.05 / ( $luminance + 0.05 ) ? '#000000' : '#ffffff'; } public static function sanitize_schema( $raw ) { if ( class_exists( 'KCONV_Edition_Boundary' ) && ! KCONV_Edition_Boundary::processing_available() && KCONV_Edition_Boundary::moved_features( $raw ) ) { return KCONV_Edition_Boundary::error(); } if ( ! is_array( $raw ) || ! isset( $raw['fields'] ) || ! is_array( $raw['fields'] ) || count( $raw['fields'] ) > self::MAX_FIELDS ) { return new WP_Error( 'schema', __( 'Use between 1 and 50 fields.', 'killerconversions' ) ); } $fields = array(); $ids = array(); foreach ( $raw['fields'] as $field ) { if ( ! is_array( $field ) ) { return new WP_Error( 'schema', __( 'Invalid field.', 'killerconversions' ) ); } $id = isset( $field['id'] ) && is_string( $field['id'] ) ? $field['id'] : ''; $type = isset( $field['type'] ) && is_string( $field['type'] ) ? $field['type'] : ''; if ( ! preg_match( '/^[a-z][a-z0-9_]{0,39}$/D', $id ) || isset( $ids[ $id ] ) || ! in_array( $type, apply_filters( 'kconv_allowed_field_types', array( 'text', 'email', 'textarea', 'select', 'radio', 'checkbox', 'number', 'tel', 'url', 'checkbox_group', 'date', 'time', 'hidden' ) ), true ) ) { return new WP_Error( 'schema', __( 'Every field needs a unique valid ID and a supported type.', 'killerconversions' ) ); } $ids[ $id ] = true; $label = isset( $field['label'] ) && is_string( $field['label'] ) ? sanitize_text_field( $field['label'] ) : ''; if ( '' === $label || KCONV_Field_Rules::length( $label ) > 200 ) { return new WP_Error( 'schema', __( 'Field labels must be 1–200 characters.', 'killerconversions' ) ); } $options = array(); if ( in_array( $type, array( 'select', 'radio', 'checkbox_group' ), true ) ) { if ( empty( $field['options'] ) || ! is_array( $field['options'] ) || count( $field['options'] ) > 100 ) { return new WP_Error( 'schema', __( 'Choice fields need between 1 and 100 options.', 'killerconversions' ) ); } foreach ( $field['options'] as $option ) { if ( ! is_string( $option ) || KCONV_Field_Rules::length( $option ) > 200 || '' === trim( $option ) ) { return new WP_Error( 'schema', __( 'Invalid choice option.', 'killerconversions' ) ); } $clean_option = sanitize_text_field( $option ); if ( '' === $clean_option ) { return new WP_Error( 'schema', __( 'Choice options must contain visible text.', 'killerconversions' ) ); } $options[] = $clean_option; } $options = array_values( array_unique( $options ) ); } $clean_field = array( 'id' => $id, 'type' => $type, 'width' => isset( $field['width'] ) && is_string( $field['width'] ) && 'half' === $field['width'] && 'hidden' !== $type ? 'half' : 'full', 'label' => $label, 'required' => 'hidden' !== $type && ! empty( $field['required'] ), 'default' => 'hidden' === $type && isset( $field['default'] ) && is_string( $field['default'] ) ? mb_substr( sanitize_text_field( $field['default'] ), 0, 1000 ) : '', 'options' => $options, 'placeholder' => isset( $field['placeholder'] ) && is_string( $field['placeholder'] ) ? KCONV_Field_Rules::truncate( sanitize_text_field( $field['placeholder'] ), 200 ) : '', 'help' => isset( $field['help'] ) && is_string( $field['help'] ) ? KCONV_Field_Rules::truncate( sanitize_text_field( $field['help'] ), 500 ) : '' ); if ( in_array( $type, array( 'checkbox', 'checkbox_group', 'radio', 'select' ), true ) ) { if ( array_key_exists( 'consent', $field ) && ! is_bool( $field['consent'] ) ) { return new WP_Error( 'schema', __( 'Consent settings must be true or false.', 'killerconversions' ) ); } // Preserve the privacy behavior of existing single checkboxes until an author marks one as an ordinary answer. $clean_field['consent'] = array_key_exists( 'consent', $field ) ? $field['consent'] : 'checkbox' === $type; } $clean_field = KCONV_Field_Rules::sanitize( $clean_field, $field ); if ( is_wp_error( $clean_field ) ) { return $clean_field; } $clean_field = apply_filters( 'kconv_sanitize_field', $clean_field, $field ); if ( is_wp_error( $clean_field ) ) { return $clean_field; } $fields[] = $clean_field; } if ( ! $fields ) { return new WP_Error( 'schema', __( 'Add at least one field.', 'killerconversions' ) ); } $input = isset( $raw['settings'] ) && is_array( $raw['settings'] ) ? $raw['settings'] : array(); $settings = self::defaults(); foreach ( array( 'submit_label' => 100, 'success_message' => 1000 ) as $key => $length ) { if ( isset( $input[ $key ] ) && is_string( $input[ $key ] ) && '' !== trim( $input[ $key ] ) ) { $settings[ $key ] = KCONV_Field_Rules::truncate( sanitize_text_field( $input[ $key ] ), $length ); } } if ( ! empty( $input['notify_email'] ) ) { if ( ! is_string( $input['notify_email'] ) || ! is_email( $input['notify_email'] ) ) { return new WP_Error( 'schema', __( 'Use one valid notification email address.', 'killerconversions' ) ); } $settings['notify_email'] = sanitize_email( $input['notify_email'] ); } if ( isset( $input['retention_days'] ) && is_scalar( $input['retention_days'] ) ) { $settings['retention_days'] = max( 1, min( 3650, absint( $input['retention_days'] ) ) ); } $settings = array_merge( $settings, self::appearance_settings( $input ) ); return apply_filters( 'kconv_sanitize_schema', array( 'fields' => $fields, 'settings' => $settings ), $raw ); } public static function get_form( $id ) { $post = get_post( absint( $id ) ); if ( ! $post || 'kconv_form' !== $post->post_type ) { return null; } $schema = get_post_meta( $post->ID, '_kconv_schema', true ); $schema = self::sanitize_schema( $schema ); if ( is_wp_error( $schema ) ) { return null; } return array_merge( array( 'id' => $post->ID, 'title' => $post->post_title, 'status' => $post->post_status ), $schema ); } public static function is_available( $form ) { if ( ! $form || 'publish' !== $form['status'] || (int) wp_get_post_parent_id( $form['id'] ) > 0 ) { return false; } // Conditional forms must not change their meaning if their add-on is inactive. if ( get_post_meta( $form['id'], '_kconv_pro_required', true ) && ! class_exists( 'KCONV_Pro' ) ) { return false; } return apply_filters( 'kconv_form_available', true, $form ); } /** Public meaning only; credentials and delivery settings never enter page HTML. */ public static function revision( $form ) { $meaning = array( 'fields' => $form['fields'], 'rules' => array() ); if ( class_exists( 'KCONV_Resume' ) ) { $meaning = KCONV_Resume::snapshot( $form ); } foreach ( array( 'estimate', 'steps', 'logic' ) as $key ) { if ( ! empty( $form[ $key ] ) ) { $meaning[ $key ] = $form[ $key ]; } } return hash( 'sha256', wp_json_encode( $meaning ) ); } public static function entry_snapshot( $form ) { $fields = array(); foreach ( $form['fields'] as $field ) { $fields[] = array_intersect_key( $field, array_flip( array( 'id', 'label', 'type', 'options', 'calculation', 'condition', 'validation', 'consent', 'repeater' ) ) ); } return wp_json_encode( array( 'version' => KCONV_Repeaters::has( $form ) ? 3 : 2, 'title' => $form['title'], 'fields' => $fields, 'revision' => self::revision( $form ), 'estimate' => isset( $form['estimate'] ) ? $form['estimate'] : null ) ); } public static function render( $id ) { $form = self::get_form( $id ); if ( ! self::is_available( $form ) ) { return '

' . esc_html__( 'This form is currently unavailable.', 'killerconversions' ) . '

'; } return self::render_form( $form ); } /** One markup renderer for published forms and the isolated administrator preview. */ public static function render_form( $form, $options = array() ) { $preview = ! empty( $options['preview'] ); if ( $preview ) { $form['id'] = 0; $form['_kconv_preview'] = true; $form['_kconv_preview_pro'] = isset( $options['pro'] ) && is_array( $options['pro'] ) ? $options['pro'] : array(); } else { wp_enqueue_style( 'kconv-forms' ); wp_enqueue_script( 'kconv-forms' ); } $instance = wp_unique_id( 'kconv-' . $form['id'] . '-' ); $public = array( 'id' => $form['id'], 'fields' => $form['fields'], 'revision' => $preview ? '' : self::revision( $form ), 'messages' => array( 'group' => __( 'Choose at least one option.', 'killerconversions' ), 'sending' => __( 'Sending your message…', 'killerconversions' ), 'unavailable' => __( 'This form is unavailable. Please reload the page.', 'killerconversions' ), 'failed' => __( 'Your message could not be sent. Please try again.', 'killerconversions' ), 'connection' => __( 'Connection interrupted. Keep this page open and retry with the same answers; a completed attempt will not be saved twice.', 'killerconversions' ), 'summary' => __( 'Please check these fields:', 'killerconversions' ) ) ); if ( $preview ) { if ( class_exists( 'KCONV_Pro' ) ) { $public = KCONV_Pro::public_schema( $public, $form ); } $public = apply_filters( 'kconv_public_schema', $public, $form ); } else { $public = apply_filters( 'kconv_public_schema', $public, $form ); } $appearance = self::appearance_settings( $form['settings'] ); $attributes = in_array( 'half', array_column( $form['fields'], 'width' ), true ) ? ' data-kconv-layout="columns"' : ''; foreach ( array( 'style', 'accent', 'density', 'font', 'width', 'radius' ) as $key ) { $attributes .= ' data-kconv-' . $key . '="' . esc_attr( $appearance[ 'appearance_' . $key ] ) . '"'; } if ( '' !== $appearance['appearance_color'] ) { $attributes .= ' data-kconv-custom-accent="1" style="' . esc_attr( '--kconv-accent:' . $appearance['appearance_color'] . ';--kconv-accent-ink:' . self::accent_foreground( $appearance['appearance_color'] ) . ';' ) . '"'; } $target = $preview ? ' data-kconv-preview="1" action="about:blank"' : ' data-kconv-endpoint="' . esc_url( rest_url( 'killerconversions/v1/' ) ) . '" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '"'; $html = '
'; if ( ! $preview ) { $html .= ''; } if ( '' !== $appearance['public_heading'] || '' !== $appearance['public_description'] ) { $html .= '
'; if ( '' !== $appearance['public_heading'] ) { $html .= '

' . esc_html( $appearance['public_heading'] ) . '

'; } if ( '' !== $appearance['public_description'] ) { $html .= '

' . esc_html( $appearance['public_description'] ) . '

'; } $html .= '
'; } $html .= ''; $html .= '
'; foreach ( $form['fields'] as $field ) { $fid = $instance . '-' . $field['id']; $name = 'values[' . $field['id'] . ']'; $required = $field['required'] ? ' required aria-required="true"' : ''; $described = ' aria-describedby="' . esc_attr( $fid . '-help ' . $fid . '-error' ) . '"'; $label = esc_html( $field['label'] ) . ( $field['required'] ? ' ' : '' ); $part = '
'; if ( 'hidden' === $field['type'] ) { $html .= ''; continue; } if ( 'checkbox_group' === $field['type'] ) { $part .= '' . $label . ''; foreach ( $field['options'] as $i => $option ) { $part .= ''; } $part .= ''; } elseif ( 'radio' === $field['type'] ) { $part .= '' . $label . ''; foreach ( $field['options'] as $i => $option ) { $part .= ''; } $part .= ''; } elseif ( 'checkbox' === $field['type'] ) { $part .= ''; } else { $part .= ''; $attrs = ' id="' . esc_attr( $fid ) . '" name="' . esc_attr( $name ) . '"' . $required . $described . KCONV_Field_Rules::attributes( $field ); if ( 'textarea' === $field['type'] ) { $part .= ''; } elseif ( 'select' === $field['type'] ) { $part .= ''; foreach ( $field['options'] as $option ) { $part .= ''; } $part .= ''; } else { $part .= ''; } } $part .= '' . esc_html( $field['help'] ) . '
'; if ( $preview ) { $part = KCONV_Repeaters::render( $part, $field, $form, $instance ); if ( class_exists( 'KCONV_Calculations' ) ) { $part = KCONV_Calculations::render_field( $part, $field, $form, $instance ); } $part = KCONV_Conditions::render_field( $part, $field, $form, $instance ); if ( class_exists( 'KCONV_Uploads' ) ) { $part = KCONV_Uploads::render_field( $part, $field, $form, $instance ); } if ( class_exists( 'KCONV_Pro' ) ) { $part = KCONV_Pro::render_field( $part, $field, $form, $instance ); } if ( class_exists( 'KCONV_Advanced_Logic' ) ) { $part = KCONV_Advanced_Logic::render_field( $part, $field, $form, $instance ); } } else { $part = apply_filters( 'kconv_render_field', $part, $field, $form, $instance ); } $processor = new WP_HTML_Tag_Processor( $part ); if ( $processor->next_tag( array( 'class_name' => 'kconv-field' ) ) ) { $processor->set_attribute( 'data-kconv-width', isset( $field['width'] ) && 'half' === $field['width'] ? 'half' : 'full' ); } $html .= $processor->get_updated_html(); } $html .= '
'; if ( $preview ) { $html = KCONV_Conditions::render_form( $html, $form, $instance ); if ( class_exists( 'KCONV_Uploads' ) ) { $html = KCONV_Uploads::render_form( $html, $form ); } if ( class_exists( 'KCONV_Pro' ) ) { $html = KCONV_Pro::render_form( $html, $form, $instance ); } if ( class_exists( 'KCONV_Advanced_Logic' ) ) { $html = KCONV_Advanced_Logic::render_form( $html, $form, $instance ); } if ( class_exists( 'KCONV_Estimates' ) ) { $html = KCONV_Estimates::render( $html, $form, $instance ); } return $html; } do_action( 'kconv_form_rendered', $form ); return apply_filters( 'kconv_render_form', $html, $form, $instance ); } public static function routes() { register_rest_route( 'killerconversions/v1', '/admin/forms', array( 'methods' => 'GET', 'permission_callback' => array( __CLASS__, 'can_list_forms' ), 'callback' => array( __CLASS__, 'list_forms' ), ) ); register_rest_route( 'killerconversions/v1', '/token/(?P\d+)', array( 'methods' => 'GET', 'permission_callback' => '__return_true', 'callback' => function ( $request ) { $form = self::get_form( $request['id'] ); if ( ! self::is_available( $form ) ) { return new WP_Error( 'unavailable', __( 'Form unavailable.', 'killerconversions' ), array( 'status' => 404 ) ); } $response = new WP_REST_Response( array( 'token' => wp_create_nonce( 'kconv_submit_' . $form['id'] ) ) ); $response->header( 'Cache-Control', 'no-store, private, max-age=0' ); return $response; } ) ); register_rest_route( 'killerconversions/v1', '/submit', array( 'methods' => 'POST', 'permission_callback' => '__return_true', 'callback' => array( __CLASS__, 'submit' ) ) ); } /** The private form catalogue has the same capability boundary as its post type. */ public static function can_list_forms() { return current_user_can( 'manage_options' ); } /** Return only root forms supported by the administrator's form builder. */ public static function list_forms( $request ) { if ( ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'kconv_forms_forbidden', __( 'You are not allowed to view the form catalogue.', 'killerconversions' ), array( 'status' => rest_authorization_required_code() ) ); } $include = isset( $request['include'] ) && is_scalar( $request['include'] ) ? absint( $request['include'] ) : 0; $search = isset( $request['search'] ) && is_string( $request['search'] ) ? sanitize_text_field( $request['search'] ) : ''; $query = new WP_Query( array( 'post_type' => 'kconv_form', 'post_parent' => 0, 'post_status' => array( 'publish', 'draft' ), 'post__in' => $include ? array( $include ) : array(), 'posts_per_page' => 50, 's' => $search, 'orderby' => 'title', 'order' => 'ASC', 'no_found_rows' => true, ) ); $rows = array(); foreach ( $query->posts as $post ) { $rows[] = array( 'id' => $post->ID, 'title' => $post->post_title, 'status' => $post->post_status, 'edit_url' => admin_url( 'admin.php?page=killerconversions&edit=' . $post->ID ), ); } $response = new WP_REST_Response( $rows ); $response->header( 'Cache-Control', 'no-store, private' ); return $response; } public static function limited( $form_id ) { global $wpdb; $ip = isset( $_SERVER['REMOTE_ADDR'] ) && is_string( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : 'unknown'; $window = (int) floor( time() / 60 ); $bucket = hash_hmac( 'sha256', $form_id . '|' . $ip . '|' . $window, wp_salt( 'nonce' ) ); $table = self::table( 'limits' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $ok = $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (bucket,hits,expires) VALUES (%s,1,%d) ON DUPLICATE KEY UPDATE hits=hits+1', $table, $bucket, time() + 120 ) ); if ( false === $ok ) { return true; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $hits = $wpdb->get_var( $wpdb->prepare( 'SELECT hits FROM %i WHERE bucket=%s', $table, $bucket ) ); return $wpdb->last_error || null === $hits || (int) $hits > 10; } public static function validate_values( $fields, $raw ) { $values = array(); $errors = array(); foreach ( $fields as $field ) { $id = $field['id']; $value = isset( $raw[ $id ] ) ? $raw[ $id ] : ''; $custom = apply_filters( 'kconv_validate_field_value', null, $field, $value ); if ( is_wp_error( $custom ) ) { $errors[ $id ] = $custom->get_error_message(); continue; } if ( is_array( $custom ) && array_key_exists( 'value', $custom ) ) { $values[ $id ] = $custom['value']; continue; } if ( 'hidden' === $field['type'] ) { $values[ $id ] = $field['default']; continue; } if ( 'checkbox_group' === $field['type'] ) { if ( '' === $value ) { $value = array(); } if ( ! is_array( $value ) || count( $value ) > 100 ) { $errors[ $id ] = __( 'Choose available options.', 'killerconversions' ); continue; } $selected = array(); foreach ( $value as $choice ) { if ( ! is_string( $choice ) || ! in_array( $choice, $field['options'], true ) ) { $errors[ $id ] = __( 'Choose available options.', 'killerconversions' ); break; } $selected[] = $choice; } $values[ $id ] = array_values( array_intersect( $field['options'], array_unique( $selected ) ) ); if ( $field['required'] && ! $values[ $id ] ) { $errors[ $id ] = __( 'Choose at least one option.', 'killerconversions' ); } continue; } if ( ! is_string( $value ) && ! is_numeric( $value ) && ! is_bool( $value ) ) { $errors[ $id ] = __( 'Enter a single value.', 'killerconversions' ); continue; } $value = trim( (string) $value ); if ( 'checkbox' === $field['type'] ) { if ( ! in_array( $value, array( '', '0', '1' ), true ) ) { $errors[ $id ] = __( 'Invalid checkbox value.', 'killerconversions' ); continue; } $value = '1' === $value ? '1' : ''; } if ( $field['required'] && '' === $value ) { $errors[ $id ] = __( 'This field is required.', 'killerconversions' ); continue; } $limit = 'textarea' === $field['type'] ? 10000 : 1000; if ( KCONV_Field_Rules::length( $value ) > $limit ) { $errors[ $id ] = __( 'This value is too long.', 'killerconversions' ); continue; } if ( '' !== $value ) { if ( 'date' === $field['type'] && ! self::valid_date( $value ) ) { $errors[ $id ] = __( 'Enter a valid date (YYYY-MM-DD).', 'killerconversions' ); } if ( 'time' === $field['type'] && ! preg_match( '/^(?:[01]\d|2[0-3]):[0-5]\d$/D', $value ) ) { $errors[ $id ] = __( 'Enter a valid time (HH:MM).', 'killerconversions' ); } if ( 'email' === $field['type'] && ( ! is_email( $value ) || strlen( $value ) > 191 ) ) { $errors[ $id ] = __( 'Enter a valid email address.', 'killerconversions' ); } if ( 'url' === $field['type'] && ( ! filter_var( $value, FILTER_VALIDATE_URL ) || ! in_array( strtolower( (string) wp_parse_url( $value, PHP_URL_SCHEME ) ), array( 'http', 'https' ), true ) ) ) { $errors[ $id ] = __( 'Enter a valid http or https URL.', 'killerconversions' ); } if ( 'number' === $field['type'] && ( ! is_numeric( $value ) || ! is_finite( (float) $value ) ) ) { $errors[ $id ] = __( 'Enter a valid number.', 'killerconversions' ); } if ( in_array( $field['type'], array( 'select', 'radio' ), true ) && ! in_array( $value, $field['options'], true ) ) { $errors[ $id ] = __( 'Choose an available option.', 'killerconversions' ); } } $clean = 'textarea' === $field['type'] ? sanitize_textarea_field( $value ) : ( 'email' === $field['type'] ? sanitize_email( $value ) : sanitize_text_field( $value ) ); if ( $field['required'] && '' === $clean && ! isset( $errors[ $id ] ) ) { $errors[ $id ] = __( 'This field is required.', 'killerconversions' ); } if ( ! isset( $errors[ $id ] ) ) { $constraint_error = KCONV_Field_Rules::validate( $field, $clean ); if ( $constraint_error ) { $errors[ $id ] = $constraint_error; } } $values[ $id ] = $clean; } return array( $values, $errors ); } public static function submit( $request ) { global $wpdb; if ( strlen( (string) $request->get_body() ) > (int) apply_filters( 'kconv_submission_max_bytes', 65536 ) ) { return new WP_Error( 'payload', __( 'The submission is too large.', 'killerconversions' ), array( 'status' => 413 ) ); } $data = $request->get_json_params(); if ( ! is_array( $data ) ) { $data = $request->get_body_params(); } $data = apply_filters( 'kconv_submission_data', $data, $request ); if ( is_wp_error( $data ) ) { return $data; } if ( ! is_array( $data ) || empty( $data['form_id'] ) || ! is_scalar( $data['form_id'] ) ) { return new WP_Error( 'form', __( 'Invalid form.', 'killerconversions' ), array( 'status' => 400 ) ); } $form = self::get_form( absint( $data['form_id'] ) ); if ( ! self::is_available( $form ) ) { return new WP_Error( 'form', __( 'This form is unavailable.', 'killerconversions' ), array( 'status' => 404 ) ); } $origin = $request->get_header( 'origin' ); if ( $origin && strtolower( (string) wp_parse_url( $origin, PHP_URL_HOST ) ) !== strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ) { return new WP_Error( 'origin', __( 'Please submit this form from this website.', 'killerconversions' ), array( 'status' => 403 ) ); } if ( empty( $data['token'] ) || ! is_string( $data['token'] ) ) { return new WP_Error( 'token', __( 'The form expired. Reload this page and try again.', 'killerconversions' ), array( 'status' => 403 ) ); } if ( ! wp_verify_nonce( sanitize_text_field( $data['token'] ), 'kconv_submit_' . $form['id'] ) ) { return new WP_Error( 'token', __( 'The form expired. Reload this page and try again.', 'killerconversions' ), array( 'status' => 403 ) ); } if ( self::limited( $form['id'] ) ) { return new WP_Error( 'rate_limit', __( 'Too many attempts. Please wait a minute and try again.', 'killerconversions' ), array( 'status' => 429 ) ); } if ( ! empty( $data['website_check'] ) ) { return new WP_Error( 'spam', __( 'Unable to accept this submission.', 'killerconversions' ), array( 'status' => 400 ) ); } $raw = isset( $data['values'] ) && is_array( $data['values'] ) ? $data['values'] : array(); if ( strlen( wp_json_encode( $raw, JSON_UNESCAPED_SLASHES ) ) > (int) apply_filters( 'kconv_submission_max_bytes', 65536 ) || count( $raw ) > self::MAX_FIELDS ) { return new WP_Error( 'payload', __( 'The submission is too large.', 'killerconversions' ), array( 'status' => 413 ) ); } $receipt = KCONV_Receipts::identity( $data, $form ); if ( is_wp_error( $receipt ) ) { return $receipt; } $replay = KCONV_Receipts::lookup( $receipt ); if ( null !== $replay ) { return $replay; } if ( isset( $data['form_revision'] ) && ( ! is_string( $data['form_revision'] ) || ! hash_equals( self::revision( $form ), $data['form_revision'] ) ) ) { return new WP_Error( 'form_changed', __( 'This form changed while this page was open. Keep a copy of your answers, then reload and review the updated questions before submitting.', 'killerconversions' ), array( 'status' => 409 ) ); } $preflight = apply_filters( 'kconv_submission_preflight', null, $data, $form, $request ); if ( is_wp_error( $preflight ) ) { return $preflight; } $context = array( 'measurement_session' => isset( $data['measurement_session'] ) && is_string( $data['measurement_session'] ) ? $data['measurement_session'] : '' ); $context = apply_filters( 'kconv_submission_context', $context, $data, $form ); if ( is_wp_error( $context ) ) { return $context; } if ( ! is_array( $context ) ) { return new WP_Error( 'configuration', __( 'Form configuration needs administrator attention.', 'killerconversions' ), array( 'status' => 503 ) ); } $raw = apply_filters( 'kconv_submission_values', $raw, $context, $data, $form ); if ( is_wp_error( $raw ) ) { return $raw; } if ( ! is_array( $raw ) || count( $raw ) > self::MAX_FIELDS || strlen( wp_json_encode( $raw, JSON_UNESCAPED_SLASHES ) ) > (int) apply_filters( 'kconv_submission_max_bytes', 65536 ) ) { return new WP_Error( 'payload', __( 'The submission is too large.', 'killerconversions' ), array( 'status' => 413 ) ); } $active = apply_filters( 'kconv_active_fields', $form['fields'], $raw, $form ); $validated = apply_filters( 'kconv_validated_submission', self::validate_values( $active, $raw ), $active, $raw, $form ); if ( ! is_array( $validated ) || ! isset( $validated[0], $validated[1] ) || ! is_array( $validated[0] ) || ! is_array( $validated[1] ) ) { return new WP_Error( 'configuration', __( 'Form validation needs administrator attention.', 'killerconversions' ), array( 'status' => 503 ) ); } list( $values, $errors ) = $validated; $errors = apply_filters( 'kconv_validate_submission', $errors, $values, $form, $active ); if ( $errors ) { return new WP_Error( 'validation', __( 'Please check the highlighted fields.', 'killerconversions' ), array( 'status' => 422, 'fields' => $errors ) ); } $entry = array( 'form_id' => $form['id'], 'created_at' => current_time( 'mysql', true ), 'expires_at' => gmdate( 'Y-m-d H:i:s', time() + $form['settings']['retention_days'] * DAY_IN_SECONDS ), 'payload' => wp_json_encode( $values ), 'schema_snapshot' => self::entry_snapshot( $form ), 'mail_status' => apply_filters( 'kconv_entry_mail_status', $form['settings']['notify_email'] ? 'pending' : 'disabled', $form ) ); // The entry, privacy lookup rows and Pro delivery outbox commit together. // Refuse nontransactional storage instead of claiming a partial save succeeded. $tables = apply_filters( 'kconv_entry_transaction_tables', array( self::table( 'entries' ), self::table( 'subjects' ), self::table( 'receipts' ) ), $form ); foreach ( $tables as $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $engine = $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ); if ( 'innodb' !== strtolower( (string) $engine ) ) { return new WP_Error( 'storage_engine', __( 'Form storage needs administrator attention. Please try again later.', 'killerconversions' ), array( 'status' => 503 ) ); } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. if ( false === $wpdb->query( 'START TRANSACTION' ) ) { return new WP_Error( 'storage', __( 'We could not save your message. Please try again.', 'killerconversions' ), array( 'status' => 503 ) ); } try { $claimed = KCONV_Receipts::claim( $receipt, $form['settings']['success_message'] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. if ( null !== $claimed ) { $wpdb->query( 'ROLLBACK' ); return $claimed; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. if ( false === $wpdb->insert( self::table( 'entries' ), $entry, array( '%d', '%s', '%s', '%s', '%s', '%s' ) ) ) { throw new RuntimeException( 'entry' ); } $entry_id = (int) $wpdb->insert_id; foreach ( $active as $field ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. if ( 'email' === $field['type'] && ! empty( $values[ $field['id'] ] ) && false === $wpdb->replace( self::table( 'subjects' ), array( 'entry_id' => $entry_id, 'email' => strtolower( $values[ $field['id'] ] ) ), array( '%d', '%s' ) ) ) { throw new RuntimeException( 'subject' ); } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. foreach ( KCONV_Repeaters::emails( $values, $active ) as $email ) { if ( false === $wpdb->replace( self::table( 'subjects' ), array( 'entry_id' => $entry_id, 'email' => $email ), array( '%d', '%s' ) ) ) { throw new RuntimeException( 'subject' ); } } $storage_errors = apply_filters( 'kconv_entry_storage_errors', new WP_Error(), $entry_id, $form, $values, $context ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. if ( ! is_wp_error( $storage_errors ) || $storage_errors->has_errors() || ! KCONV_Receipts::complete( $receipt, $entry_id ) || false === $wpdb->query( 'COMMIT' ) ) { throw new RuntimeException( 'outbox' ); } } catch ( Throwable $error ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $wpdb->query( 'ROLLBACK' ); return apply_filters( 'kconv_submission_storage_error', new WP_Error( 'storage', __( 'We could not save your message. Please try again.', 'killerconversions' ), array( 'status' => 503 ) ), isset( $storage_errors ) ? $storage_errors : null ); } // The visitor's success reflects durable storage, even when a post-commit extension fails. try { do_action( 'kconv_entry_created', $entry_id, $form, $values, $context ); } catch ( Throwable $error ) { update_option( 'kconv_postcommit_problem', array( 'entry_id' => $entry_id, 'time' => time(), 'stage' => 'extension' ), false ); } try { if ( $form['settings']['notify_email'] && apply_filters( 'kconv_send_notification_inline', true, $entry_id, $form ) ) { /* translators: %d: saved entry ID. */ $lines = array( sprintf( __( 'New entry #%d', 'killerconversions' ), $entry_id ), $form['title'], '' ); foreach ( $active as $field ) { $lines[] = $field['label'] . ': ' . self::value_text( isset( $values[ $field['id'] ] ) ? $values[ $field['id'] ] : '' ); } /* translators: %s: form title. */ $subject = sprintf( __( 'New form entry: %s', 'killerconversions' ), $form['title'] ); $sent = wp_mail( $form['settings']['notify_email'], $subject, implode( "\n", $lines ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $wpdb->update( self::table( 'entries' ), array( 'mail_status' => $sent ? 'accepted' : 'failed' ), array( 'id' => $entry_id ), array( '%s' ), array( '%d' ) ); } } catch ( Throwable $error ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $wpdb->update( self::table( 'entries' ), array( 'mail_status' => 'failed' ), array( 'id' => $entry_id ), array( '%s' ), array( '%d' ) ); update_option( 'kconv_postcommit_problem', array( 'entry_id' => $entry_id, 'time' => time(), 'stage' => 'notification' ), false ); } return new WP_REST_Response( apply_filters( 'kconv_submission_response', array( 'success' => true, 'message' => $form['settings']['success_message'] ), $entry_id, $values ), 201 ); } /** Return a real navigation link for visitors who have JavaScript disabled. */ private static function fallback_back_link() { $url = home_url( '/' ); $label = __( 'Back to website', 'killerconversions' ); $referer = isset( $_SERVER['HTTP_REFERER'] ) && is_string( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ), array( 'http', 'https' ) ) : ''; $home = wp_parse_url( $url ); $parts = wp_parse_url( $referer ); if ( is_array( $home ) && is_array( $parts ) && isset( $home['scheme'], $home['host'], $parts['scheme'], $parts['host'] ) && ! isset( $parts['user'] ) && ! isset( $parts['pass'] ) ) { $home_port = isset( $home['port'] ) ? (int) $home['port'] : ( 'https' === strtolower( $home['scheme'] ) ? 443 : 80 ); $referer_port = isset( $parts['port'] ) ? (int) $parts['port'] : ( 'https' === strtolower( $parts['scheme'] ) ? 443 : 80 ); if ( strtolower( $home['scheme'] ) === strtolower( $parts['scheme'] ) && strtolower( $home['host'] ) === strtolower( $parts['host'] ) && $home_port === $referer_port && ( $parts['path'] ?? '/' ) !== wp_parse_url( admin_url( 'admin-post.php' ), PHP_URL_PATH ) ) { $url = $referer; $label = __( 'Back to form', 'killerconversions' ); } } return '

' . esc_html( $label ) . '

'; } public static function fallback() { $back_link = self::fallback_back_link(); if ( ! isset( $_POST['form_id'] ) || ! is_scalar( $_POST['form_id'] ) ) { wp_die( esc_html__( 'Invalid form.', 'killerconversions' ) . wp_kses_post( $back_link ), '', array( 'response' => 400, 'back_link' => false ) ); } $form_id = absint( $_POST['form_id'] ); if ( ! isset( $_POST['token'] ) || ! is_string( $_POST['token'] ) ) { wp_die( esc_html__( 'The form expired. Reload this page and try again.', 'killerconversions' ) . wp_kses_post( $back_link ), '', array( 'response' => 400, 'back_link' => false ) ); } $nonce = sanitize_text_field( wp_unslash( $_POST['token'] ) ); if ( ! wp_verify_nonce( $nonce, 'kconv_submit_' . $form_id ) ) { wp_die( esc_html__( 'The form expired. Reload this page and try again.', 'killerconversions' ) . wp_kses_post( $back_link ), '', array( 'response' => 400, 'back_link' => false ) ); } $request = new WP_REST_Request( 'POST', '/killerconversions/v1/submit' ); if ( isset( $_SERVER['HTTP_ORIGIN'] ) && is_string( $_SERVER['HTTP_ORIGIN'] ) ) { $request->set_header( 'origin', sanitize_text_field( wp_unslash( $_SERVER['HTTP_ORIGIN'] ) ) ); } $request->set_file_params( $_FILES ); $request->set_body_params( wp_unslash( $_POST ) ); // submit() repeats public-token and Origin checks before persistence. $result = self::submit( $request ); $message = is_wp_error( $result ) ? $result->get_error_message() : $result->get_data()['message']; if ( is_wp_error( $result ) ) { $data = $result->get_error_data(); if ( isset( $data['fields'] ) ) { $message .= ' ' . implode( ' ', array_values( $data['fields'] ) ); } } $estimate_summary = ! is_wp_error( $result ) && ! empty( $result->get_data()['estimate'] ) ? '
' . esc_html( KCONV_Saved_Estimates::text( $result->get_data()['estimate'] ) ) . '
' : ''; wp_die( '
' . esc_html( $message ) . wp_kses_post( $estimate_summary ) . '
' . wp_kses_post( $back_link ), esc_html__( 'Form submission', 'killerconversions' ), array( 'response' => is_wp_error( $result ) ? 400 : 200, 'back_link' => false ) ); } public static function related_tables() { global $wpdb; $existing = array(); foreach ( array( 'receipts', 'pro_delivery', 'delivery_jobs', 'delivery_events', 'outcomes', 'outcome_audit', 'review', 'review_audit', 'sessions', 'metric_events', 'files' ) as $suffix ) { $table = self::table( $suffix ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); if ( $wpdb->last_error ) { return new WP_Error( 'storage', __( 'Entry storage could not be inspected.', 'killerconversions' ) ); } if ( $found === $table ) { $existing[ $suffix ] = $table; } } return $existing; } public static function delete_entries( $ids, $authorize = false ) { global $wpdb; $ids = array_values( array_filter( array_map( 'absint', $ids ) ) ); if ( ! $ids ) { return true; } $related = self::related_tables(); if ( is_wp_error( $related ) ) { return false; } // Privacy deletion shares one transaction with private files and add-on records. foreach ( array_merge( array( self::table( 'entries' ), self::table( 'subjects' ) ), array_values( $related ) ) as $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $engine = $wpdb->get_var( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ) ); if ( 'innodb' !== strtolower( (string) $engine ) ) { return false; } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. if ( false === $wpdb->query( 'START TRANSACTION' ) ) { return false; } $list = implode( ',', $ids ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $wpdb->get_col( $wpdb->prepare( "SELECT id FROM %i WHERE id IN ($list) FOR UPDATE", self::table( 'entries' ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. if ( $authorize && ! KCONV_Entry_Access::assert_ids( $ids, 'delete' ) ) { $wpdb->query( 'ROLLBACK' ); return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. if ( $wpdb->last_error || ! self::purge_related( $ids, $related ) || false === $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE entry_id IN ($list)", self::table( 'subjects' ) ) ) || false === $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE id IN ($list)", self::table( 'entries' ) ) ) || false === $wpdb->query( 'COMMIT' ) ) { $wpdb->query( 'ROLLBACK' ); return false; } do_action( 'kconv_entries_deleted', $ids ); return true; } /** Erasure works even when the add-on that created related data is inactive. */ public static function purge_related( $ids, $existing = null ) { global $wpdb; $ids = array_values( array_filter( array_map( 'absint', (array) $ids ) ) ); if ( ! $ids ) { return true; } $list = implode( ',', $ids ); if ( null === $existing ) { $existing = self::related_tables(); } if ( is_wp_error( $existing ) ) { return false; } if ( isset( $existing['sessions'], $existing['metric_events'] ) ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. if ( false === $wpdb->query( $wpdb->prepare( "DELETE v FROM %i v INNER JOIN %i s ON s.session_hash=v.session_hash WHERE s.entry_id IN ($list)", $existing['metric_events'], $existing['sessions'] ) ) ) { return false; } } foreach ( $existing as $suffix => $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. if ( 'metric_events' !== $suffix && false === $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE entry_id IN ($list)", $table ) ) ) { return false; } } return true; } /** Keep a backlog moving without replacing the daily fallback or creating duplicate workers. */ private static function continue_cleanup() { if ( wp_next_scheduled( 'kconv_cleanup_continue' ) ) { return true; } $scheduled = wp_schedule_single_event( time() + MINUTE_IN_SECONDS, 'kconv_cleanup_continue', array(), true ); // A competing caller may have scheduled the same event after our first check. return ( ! is_wp_error( $scheduled ) && $scheduled ) || (bool) wp_next_scheduled( 'kconv_cleanup_continue' ); } public static function cleanup() { global $wpdb; $lock = 'kconv_cleanup_' . substr( hash( 'sha256', $wpdb->prefix ), 0, 40 ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $acquired = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s,0)', $lock ) ); if ( '1' !== (string) $acquired ) { // A consumed cron event must survive genuine contention. A database failure is not a busy lock. if ( '0' === (string) $acquired && ! $wpdb->last_error ) { self::continue_cleanup(); } return; } $report = array( 'deleted' => 0, 'batches' => 0, 'errors' => array(), 'remaining' => null ); $deadline = microtime( true ) + 10; try { do_action( 'kconv_cleanup_started' ); $table = self::table( 'entries' ); $now = current_time( 'mysql', true ); for ( $i = 0; $i < 20; $i++ ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT id FROM %i WHERE expires_at < %s ORDER BY id LIMIT 500', $table, $now ) ); if ( $wpdb->last_error ) { $report['errors'][] = 'read'; break; } if ( ! $ids ) { break; } if ( ! self::delete_entries( $ids ) ) { $report['errors'][] = 'delete'; break; } $report['deleted'] += count( $ids ); $report['batches']++; if ( count( $ids ) < 500 || microtime( true ) >= $deadline ) { break; } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $remaining = $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE expires_at < %s', $table, $now ) ); if ( $wpdb->last_error ) { $report['errors'][] = 'count'; } else { $report['remaining'] = (int) $remaining; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. if ( false === $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE expires < %d', self::table( 'limits' ), time() ) ) ) { $report['errors'][] = 'limits'; } if ( ! $report['errors'] && $report['remaining'] > 0 && ! self::continue_cleanup() ) { $report['errors'][] = 'schedule'; } } catch ( Throwable $error ) { $report['errors'][] = 'exception'; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. finally { try { do_action( 'kconv_cleanup_finished', $report ); } finally { $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock ) ); } } } public static function exporters( $exporters ) { $exporters['killerconversions-files'] = array( 'exporter_friendly_name' => 'KillerConversions private files', 'callback' => array( __CLASS__, 'privacy_files' ) ); $exporters['killerconversions'] = array( 'exporter_friendly_name' => 'KillerConversions', 'callback' => array( __CLASS__, 'privacy_export' ) ); return $exporters; } public static function erasers( $erasers ) { $erasers['killerconversions'] = array( 'eraser_friendly_name' => 'KillerConversions', 'callback' => array( __CLASS__, 'privacy_erase' ) ); return $erasers; } public static function privacy_export( $email, $page = 1 ) { global $wpdb; $table = self::table( 'entries' ); $subjects = self::table( 'subjects' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT e.* FROM %i e INNER JOIN %i s ON e.id=s.entry_id WHERE s.email=%s ORDER BY e.id ASC LIMIT 100 OFFSET %d', $table, $subjects, strtolower( $email ), ( max( 1, (int) $page ) - 1 ) * 100 ), ARRAY_A ); if ( $wpdb->last_error ) { return new WP_Error( 'storage', __( 'Entries could not be exported. Please retry after storage is repaired.', 'killerconversions' ) ); } $data = array(); foreach ( $rows as $row ) { $item = array( array( 'name' => __( 'Submitted', 'killerconversions' ), 'value' => $row['created_at'] . ' UTC' ), array( 'name' => __( 'Form ID', 'killerconversions' ), 'value' => $row['form_id'] ) ); $snapshot = ! empty( $row['schema_snapshot'] ) ? json_decode( $row['schema_snapshot'], true ) : null; $labels = is_array( $snapshot ) && isset( $snapshot['fields'] ) && is_array( $snapshot['fields'] ) ? array_column( $snapshot['fields'], 'label', 'id' ) : array(); foreach ( (array) json_decode( $row['payload'], true ) as $key => $value ) { $item[] = array( 'name' => isset( $labels[ $key ] ) ? $labels[ $key ] . ' [' . $key . ']' : $key, 'value' => self::value_text( $value ) ); } $data[] = array( 'group_id' => 'killerconversions', 'group_label' => __( 'Form submissions', 'killerconversions' ), 'item_id' => 'kconv-entry-' . $row['id'], 'data' => $item ); } return array( 'data' => $data, 'done' => count( $rows ) < 100 ); } public static function privacy_erase( $email, $page = 1 ) { global $wpdb; $subjects = self::table( 'subjects' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $ids = $wpdb->get_col( $wpdb->prepare( 'SELECT entry_id FROM %i WHERE email=%s ORDER BY entry_id ASC LIMIT 100', $subjects, strtolower( $email ) ) ); if ( $wpdb->last_error ) { return new WP_Error( 'storage', __( 'The entry lookup failed. Please repair storage and retry this erasure.', 'killerconversions' ) ); } $deleted = self::delete_entries( $ids ); if ( ! $deleted ) { return new WP_Error( 'storage', __( 'Entry deletion failed and was rolled back. Ask the administrator to repair storage and retry this erasure.', 'killerconversions' ) ); } return array( 'items_removed' => ! empty( $ids ), 'items_retained' => false, 'messages' => array(), 'done' => count( $ids ) < 100 ); } public static function privacy_files( $email, $page = 1 ) { global $wpdb; $tables = self::related_tables(); if ( is_wp_error( $tables ) ) { return $tables; } if ( ! isset( $tables['files'] ) ) { return array( 'data' => array(), 'done' => true ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Plugin-owned entry/receipt/subject/retention operations use fixed table suffixes, prepared values, integer-only ID lists and a related-table allowlist; transactional insertion and erasure need current database state. $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT f.* FROM %i f INNER JOIN %i s ON s.entry_id=f.entry_id WHERE s.email=%s ORDER BY f.entry_id,f.file_id LIMIT 1 OFFSET %d', $tables['files'], self::table( 'subjects' ), strtolower( $email ), ( max( 1, (int) $page ) - 1 ) ), ARRAY_A ); if ( $wpdb->last_error ) { return new WP_Error( 'storage', __( 'Private files could not be exported. Please retry after storage is repaired.', 'killerconversions' ) ); } $items = array(); foreach ( $rows as $file ) { if ( ! is_string( $file['content'] ) || strlen( $file['content'] ) !== (int) $file['bytes'] || ! hash_equals( $file['sha256'], hash( 'sha256', $file['content'] ) ) ) { return new WP_Error( 'integrity', __( 'The stored file failed its integrity check.', 'killerconversions' ) ); } $items[] = array( 'group_id' => 'killerconversions-files', 'group_label' => 'Private form files (base64)', 'item_id' => 'kconv-file-' . $file['file_id'], 'data' => array( array( 'name' => 'Entry', 'value' => (string) $file['entry_id'] ), array( 'name' => 'Filename', 'value' => $file['name'] ), array( 'name' => 'Type', 'value' => $file['mime'] ), array( 'name' => 'Bytes', 'value' => (string) $file['bytes'] ), array( 'name' => 'SHA-256', 'value' => $file['sha256'] ), array( 'name' => 'Content (base64; decode to recover original file)', 'value' => base64_encode( $file['content'] ) ) ) ); } return array( 'data' => $items, 'done' => count( $rows ) < 1 ); } public static function privacy_policy() { if ( function_exists( 'wp_add_privacy_policy_content' ) ) { wp_add_privacy_policy_content( 'KillerConversions', '

' . esc_html__( 'Suggested text: We store the information you submit through our forms in this website’s WordPress database for the retention period configured by the website administrator. We may email the submission to the configured recipient. Short-lived, salted network-address hashes help rate-limit submissions; raw IP addresses are not stored by this plugin. If an email field is present, its address can be used by the WordPress personal-data export and erasure tools. Email copies and data sent through optional integrations must be handled separately. Review this text for your actual configuration.', 'killerconversions' ) . '

' ); } } }