has_cap( 'kconv_view_entries' ) && ! $user->has_cap( 'manage_options' ) && ( ! $requested || $requested === admin_url() ) ) { return admin_url( 'admin.php?page=kconv-entries' ); } return $redirect; } public static function authorize( $cap, $nonce ) { if ( ! KCONV_Core::can( $cap ) ) { wp_die( esc_html__( 'You do not have permission for this action.', 'killerconversions' ), '', array( 'response' => 403 ) ); } if ( ! isset( $_REQUEST['_wpnonce'] ) || ! is_string( $_REQUEST['_wpnonce'] ) ) { wp_nonce_ays( $nonce ); } $token = sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ); if ( ! wp_verify_nonce( $token, $nonce ) ) { wp_nonce_ays( $nonce ); } } public static function filters( $input ) { $f = array( 'form_id' => 0, 'search' => '', 'from' => '', 'to' => '', 'read' => '', 'starred' => '', 'assigned' => '', 'time_display' => 'site', 'paged' => 1 ); foreach ( $f as $key => $unused ) { if ( isset( $input[ $key ] ) && is_scalar( $input[ $key ] ) ) { $f[ $key ] = sanitize_text_field( (string) $input[ $key ] ); } } $f['form_id'] = absint( $f['form_id'] ); $f['search'] = mb_substr( $f['search'], 0, 200 ); $f['paged'] = max( 1, min( 100000, absint( $f['paged'] ) ) ); if ( ! in_array( $f['time_display'], array( 'site', 'utc' ), true ) ) { $f['time_display'] = 'site'; } foreach ( array( 'from', 'to' ) as $key ) { if ( '' !== $f[ $key ] && ! KCONV_Core::valid_date( $f[ $key ] ) ) { return new WP_Error( 'date', __( 'Use valid UTC dates.', 'killerconversions' ) ); } } if ( $f['from'] && $f['to'] && $f['from'] > $f['to'] ) { return new WP_Error( 'date', __( 'The end date must follow the start date.', 'killerconversions' ) ); } if ( ! in_array( $f['read'], array( '', '0', '1' ), true ) || ! in_array( $f['starred'], array( '', '1' ), true ) || ( '' !== $f['assigned'] && ! ctype_digit( $f['assigned'] ) ) ) { return new WP_Error( 'filter', __( 'Invalid entry filter.', 'killerconversions' ) ); } return $f; } public static function where( $filters ) { global $wpdb; $sql = KCONV_Entry_Access::where(); if ( $filters['form_id'] ) { $sql .= $wpdb->prepare( ' AND form_id=%d', $filters['form_id'] ); } if ( $filters['search'] ) { $sql .= $wpdb->prepare( ' AND payload LIKE %s', '%' . $wpdb->esc_like( $filters['search'] ) . '%' ); } if ( $filters['from'] ) { $sql .= $wpdb->prepare( ' AND created_at>=%s', $filters['from'] . ' 00:00:00' ); } if ( $filters['to'] ) { $sql .= $wpdb->prepare( ' AND created_at<=%s', $filters['to'] . ' 23:59:59' ); } if ( '' !== $filters['read'] ) { $sql .= $wpdb->prepare( ' AND is_read=%d', (int) $filters['read'] ); } if ( $filters['starred'] ) { $sql .= ' AND starred=1'; } if ( '' !== $filters['assigned'] ) { $sql .= $wpdb->prepare( ' AND assigned_to=%d', (int) $filters['assigned'] ); } return $sql; } public static function operators() { return get_users( array( 'capability__in' => array( 'manage_options', 'kconv_view_entries' ), 'number' => 200, 'orderby' => 'display_name', 'fields' => array( 'ID', 'display_name' ) ) ); } private static function die_error( $error ) { wp_die( esc_html( $error->get_error_message() ), '', array( 'response' => 400, 'back_link' => true ) ); } public static function assets( $hook ) { if ( ! KCONV_Admin_Scope::plugin( array( 'kconv-entries' ) ) && ! ( KCONV_Admin_Scope::plugin( array( 'killerconversions' ) ) && ! current_user_can( 'manage_options' ) ) ) { return; } wp_enqueue_style( 'kconv-inbox', KCONV_URL . 'assets/inbox.css', array( 'kconv-admin' ), KCONV_VERSION ); wp_enqueue_script( 'kconv-inbox', KCONV_URL . 'assets/inbox.js', array('wp-i18n'), KCONV_VERSION, true ); wp_set_script_translations( 'kconv-inbox', 'killerconversions', KCONV_DIR . 'languages' ); } /** Saved definitions are historical evidence; never silently backfill legacy entries. */ public static function definitions( $row ) { $raw = isset( $row['schema_snapshot'] ) ? $row['schema_snapshot'] : null; $snapshot = is_string( $raw ) ? json_decode( $raw, true ) : null; $valid = is_array( $snapshot ) && isset( $snapshot['version'], $snapshot['title'], $snapshot['fields'], $snapshot['revision'] ) && in_array( $snapshot['version'], array( 1, 2, 3 ), true ) && is_string( $snapshot['title'] ) && is_array( $snapshot['fields'] ) && count( $snapshot['fields'] ) <= KCONV_Core::MAX_FIELDS && is_string( $snapshot['revision'] ) && preg_match( '/^[a-f0-9]{64}$/D', $snapshot['revision'] ); $fields = array(); if ( $valid ) { foreach ( $snapshot['fields'] as $field ) { if ( ! is_array( $field ) || ! isset( $field['id'], $field['label'], $field['type'] ) || ! is_string( $field['id'] ) || ! preg_match( '/^[a-z][a-z0-9_]{0,39}$/D', $field['id'] ) || isset( $fields[ $field['id'] ] ) || ! is_string( $field['label'] ) || ! is_string( $field['type'] ) ) { $valid = false; break; } $fields[ $field['id'] ] = array_intersect_key( $field, array_flip( array( 'label', 'type', 'repeater' ) ) ); } } if ( $valid ) { return array( 'source' => 'submission', 'title' => $snapshot['title'], 'fields' => $fields, 'revision' => $snapshot['revision'] ); } $form = KCONV_Core::get_form( $row['form_id'] ); $fields = array(); if ( $form ) { foreach ( $form['fields'] as $field ) { $fields[ $field['id'] ] = array_intersect_key( $field, array_flip( array( 'label', 'type', 'repeater' ) ) ); } } return array( 'source' => null === $raw || '' === $raw ? 'legacy-current-labels' : 'snapshot-unavailable-current-labels', 'title' => $form ? $form['title'] : __( 'Deleted form', 'killerconversions' ), 'fields' => $fields, 'revision' => '' ); } public static function display_time( $utc, $mode = 'site' ) { $date = DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', (string) $utc, new DateTimeZone( 'UTC' ) ); if ( ! $date || $date->format( 'Y-m-d H:i:s' ) !== $utc ) { return __( 'Time unavailable', 'killerconversions' ); } $zone = 'utc' === $mode ? new DateTimeZone( 'UTC' ) : wp_timezone(); return wp_date( 'Y-m-d H:i:s', $date->getTimestamp(), $zone ) . ' ' . $zone->getName(); } public static function return_context( $input, $form_id = 0 ) { $raw = isset( $input['return_filters'] ) && is_array( $input['return_filters'] ) ? $input['return_filters'] : array( 'form_id' => $form_id ); $filters = self::filters( $raw ); return is_wp_error( $filters ) ? self::filters( array( 'form_id' => $form_id ) ) : $filters; } /** Identity is display-only; saved answers are never rewritten or inferred into new records. */ private static function identity( $row ) { $values = (array) json_decode( $row['payload'], true ); $name = ''; $email = ''; $used = array(); foreach ( array( 'name', 'full_name', 'fullname', 'your_name', 'contact_name' ) as $key ) { if ( isset( $values[ $key ] ) && is_scalar( $values[ $key ] ) && '' !== trim( (string) $values[ $key ] ) ) { $name = trim( (string) $values[ $key ] ); $used[] = $key; break; } } if ( ! $name && isset( $values['first_name'] ) && is_string( $values['first_name'] ) ) { $name = trim( $values['first_name'] . ' ' . ( isset( $values['last_name'] ) && is_string( $values['last_name'] ) ? $values['last_name'] : '' ) ); $used = array( 'first_name', 'last_name' ); } $definitions = self::definitions( $row ); foreach ( $definitions['fields'] as $id => $field ) { if ( 'email' === $field['type'] && isset( $values[ $id ] ) && is_string( $values[ $id ] ) && is_email( $values[ $id ] ) ) { $email = $values[ $id ]; $used[] = $id; break; } } if ( ! $email && isset( $values['email'] ) && is_string( $values['email'] ) && is_email( $values['email'] ) ) { $email = $values['email']; $used[] = 'email'; } $snippet = ''; foreach ( $values as $key => $value ) { if ( in_array( $key, $used, true ) || ! is_scalar( $value ) || '' === trim( (string) $value ) ) { continue; } $snippet = preg_replace( '/\s+/u', ' ', (string) $value ); break; } /* translators: %d: Stored entry ID. */ $name = $name ? mb_substr( $name, 0, 120 ) : ( $email ?: sprintf( __( 'Entry #%d', 'killerconversions' ), $row['id'] ) ); $initial = mb_substr( $name, 0, 1 ); $initial = function_exists( 'mb_strtoupper' ) ? mb_strtoupper( $initial ) : strtoupper( $initial ); return array( 'name' => $name, 'email' => $email, 'initial' => $initial, 'snippet' => mb_substr( (string) $snippet, 0, 100 ) ); } private static function mail_badge( $status ) { $labels = array( 'disabled' => __( 'Staff email off', 'killerconversions' ), 'pending' => __( 'Pending', 'killerconversions' ), 'queued' => __( 'Queued', 'killerconversions' ), 'processing' => __( 'Sending', 'killerconversions' ), 'retry' => __( 'Retry scheduled', 'killerconversions' ), 'accepted' => __( 'Accepted for sending', 'killerconversions' ), 'mail_accepted' => __( 'Accepted for sending', 'killerconversions' ), 'failed' => __( 'Needs attention', 'killerconversions' ), 'mail_failed' => __( 'Needs attention', 'killerconversions' ), 'cancelled' => __( 'Cancelled', 'killerconversions' ), 'blocked' => __( 'Blocked', 'killerconversions' ), 'exhausted' => __( 'Retries exhausted', 'killerconversions' ) ); $tone = in_array( $status, array( 'failed', 'mail_failed', 'blocked', 'exhausted' ), true ) ? 'warning' : ( in_array( $status, array( 'accepted', 'mail_accepted' ), true ) ? 'success' : 'neutral' ); echo '' . esc_html( isset( $labels[ $status ] ) ? $labels[ $status ] : ucwords( str_replace( '_', ' ', $status ) ) ) . ''; } private static function return_fields( $filters ) { foreach ( $filters as $key => $value ) { echo ''; } } private static function workflow_options() { if ( KCONV_Core::can( 'kconv_review_entries' ) ) { foreach ( array( 'read' => __( 'Mark read', 'killerconversions' ), 'unread' => __( 'Mark unread', 'killerconversions' ), 'star' => __( 'Star', 'killerconversions' ), 'unstar' => __( 'Remove star', 'killerconversions' ), 'assign' => __( 'Assign', 'killerconversions' ) ) as $value => $label ) { if ( 'assign' === $value && ! apply_filters( 'kconv_entry_assignment_inline', true ) ) { continue; } echo ''; } } if ( KCONV_Core::can( 'kconv_export_entries' ) ) { echo ''; } if ( KCONV_Core::can( 'kconv_delete_entries' ) ) { echo ''; } } public static function screen() { if ( ! KCONV_Core::can( 'kconv_view_entries' ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen() navigation/filter input; values are normalized or allowlisted and escaped at output. No state change occurs from this read; mutations use separate capability/nonce guards. $id = isset( $_GET['entry_id'] ) && is_scalar( $_GET['entry_id'] ) ? absint( $_GET['entry_id'] ) : 0; if ( $id ) { self::detail( $id ); return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen() navigation/filter input; values are normalized or allowlisted and escaped at output. No state change occurs from this read; mutations use separate capability/nonce guards. $f = self::filters( wp_unslash( $_GET ) ); if ( is_wp_error( $f ) ) { self::die_error( $f ); } global $wpdb; $table = KCONV_Core::table( 'entries' ); $where = self::where( $f ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Inbox/export/bulk queries use validated filters and entry scope; scalar values are prepared, IDs are normalized with absint and operation columns come from a fixed map. Counts and export snapshots deliberately read current private rows. $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE $where", $table ) ); if ( $wpdb->last_error ) { self::die_error( new WP_Error( 'storage', __( 'Entries could not be read. Please retry after storage is repaired.', 'killerconversions' ) ) ); } $pages = max( 1, (int) ceil( $total / 25 ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen() navigation/filter input; values are normalized or allowlisted and escaped at output. No state change occurs from this read; mutations use separate capability/nonce guards. $page = isset( $_GET['paged'] ) && is_scalar( $_GET['paged'] ) ? min( $pages, max( 1, absint( $_GET['paged'] ) ) ) : 1; // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Inbox/export/bulk queries use validated filters and entry scope; scalar values are prepared, IDs are normalized with absint and operation columns come from a fixed map. Counts and export snapshots deliberately read current private rows. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE $where ORDER BY id DESC LIMIT 25 OFFSET %d", $table, ( $page - 1 ) * 25 ), ARRAY_A ); if ( $wpdb->last_error ) { self::die_error( new WP_Error( 'storage', __( 'Entries could not be read. Please retry after storage is repaired.', 'killerconversions' ) ) ); } $f['paged'] = $page; $base = $f; $base['read'] = ''; $base['starred'] = ''; $base['assigned'] = ''; $count_where = self::where( $base ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Inbox/export/bulk queries use validated filters and entry scope; scalar values are prepared, IDs are normalized with absint and operation columns come from a fixed map. Counts and export snapshots deliberately read current private rows. $counts = $wpdb->get_row( $wpdb->prepare( "SELECT COUNT(*) AS all_entries,SUM(is_read=0) AS unread,SUM(starred=1) AS starred,SUM(assigned_to=%d) AS mine FROM %i WHERE $count_where", get_current_user_id(), $table ), ARRAY_A ); if ( $wpdb->last_error || ! is_array( $counts ) ) { self::die_error( new WP_Error( 'storage', __( 'Entry counts could not be read. Please retry after storage is repaired.', 'killerconversions' ) ) ); } $operators = self::operators(); $can_bulk = KCONV_Core::can( 'kconv_review_entries' ) || KCONV_Core::can( 'kconv_export_entries' ) || KCONV_Core::can( 'kconv_delete_entries' ); $active_filters = 0; foreach ( array( 'form_id', 'search', 'from', 'to', 'read', 'starred', 'assigned' ) as $key ) { if ( in_array( $key, array( 'form_id', 'search' ), true ) ? (bool) $f[ $key ] : '' !== $f[ $key ] ) { ++$active_filters; } } echo '
'; if ( is_callable( array( 'KCONV_Admin', 'navigation' ) ) ) { KCONV_Admin::navigation( 'entries' ); } echo '
'; echo '

' . esc_html__( 'Inbox', 'killerconversions' ) . '' . esc_html( number_format_i18n( $total ) ) . '

'; if ( current_user_can( 'manage_options' ) ) { echo '' . esc_html__( 'View forms', 'killerconversions' ) . ' '; } echo '
'; if ( class_exists( 'KCONV_Operations' ) ) { KCONV_Operations::panel(); } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only screen() navigation/filter input; values are normalized or allowlisted and escaped at output. No state change occurs from this read; mutations use separate capability/nonce guards. if ( isset( $_GET['updated'] ) && '1' === $_GET['updated'] ) { echo '

' . esc_html__( 'Entry changes saved.', 'killerconversions' ) . '

'; } echo '
' . esc_html__( 'Search & filters', 'killerconversions' ); if ( $active_filters ) { /* translators: %d: Number of active entry filters. */ echo '' . esc_html( sprintf( __( '%d active', 'killerconversions' ), $active_filters ) ) . ''; } echo '

' . esc_html__( 'View counts use the selected form, search and date filters.', 'killerconversions' ) . '

' . esc_html__( 'More filters', 'killerconversions' ) . '
'; foreach ( array( 'from' => __( 'From · UTC', 'killerconversions' ), 'to' => __( 'Through · UTC', 'killerconversions' ) ) as $key => $label ) { echo ''; } /* translators: %s: WordPress site timezone name. */ echo ''; echo '' . esc_html__( 'Reset', 'killerconversions' ) . '
'; echo '
'; if ( $can_bulk ) { echo ''; } echo '

' . esc_html( number_format_i18n( $total ) ) . ' ' . esc_html( _n( 'matching entry', 'matching entries', $total, 'killerconversions' ) ) . '

'; if ( KCONV_Core::can( 'kconv_export_entries' ) ) { echo '
' . esc_html__( 'Export CSV', 'killerconversions' ) . '
'; wp_nonce_field( 'kconv_export' ); foreach ( $f as $key => $value ) { echo ''; } echo '
'; } echo '
'; if ( $can_bulk ) { echo '
'; wp_nonce_field( 'kconv_entries_bulk' ); self::return_fields( $f ); echo '' . esc_html__( 'Select entries', 'killerconversions' ) . ''; if ( KCONV_Core::can( 'kconv_review_entries' ) && apply_filters( 'kconv_entry_assignment_inline', true ) ) { echo ''; } if ( KCONV_Core::can( 'kconv_delete_entries' ) ) { echo ''; } echo '
'; } echo '
'; if ( $can_bulk ) { echo ''; } echo ''; foreach ( $rows as $row ) { $identity = self::identity( $row ); $definitions = self::definitions( $row ); $url = add_query_arg( array( 'page' => 'kconv-entries', 'entry_id' => $row['id'], 'return_filters' => $f ), admin_url( 'admin.php' ) ); echo ''; /* translators: %d: Entry ID. */ if ( $can_bulk ) { echo ''; } echo ''; $assignee = $row['assigned_to'] ? get_userdata( $row['assigned_to'] ) : false; echo ''; } if ( ! $rows ) { echo ''; } echo '
' . esc_html__( 'Select', 'killerconversions' ) . '' . esc_html__( 'Lead', 'killerconversions' ) . '' . esc_html__( 'Form & submitted', 'killerconversions' ) . '' . esc_html__( 'Owner', 'killerconversions' ) . '' . esc_html__( 'Email & delivery', 'killerconversions' ) . '' . esc_html__( 'Open entry', 'killerconversions' ) . '
' . esc_html( $identity['name'] ) . '' . ( $row['starred'] ? '★' : '' ); if ( $identity['email'] && $identity['email'] !== $identity['name'] ) { echo '' . esc_html( $identity['email'] ) . ''; } if ( $identity['snippet'] ) { echo '' . esc_html( $identity['snippet'] ) . ''; } echo '
' . esc_html( $definitions['title'] ) . '#' . esc_html( $row['id'] ) . ' · ' . esc_html( $row['is_read'] ? __( 'Read', 'killerconversions' ) : __( 'Unread', 'killerconversions' ) ) . '
' . esc_html( $assignee ? $assignee->display_name : __( 'Unassigned', 'killerconversions' ) ) . '
'; self::mail_badge( $row['mail_status'] ); do_action( 'kconv_admin_entry_actions', $row ); /* translators: %d: Entry ID. */ echo '
' . esc_html__( 'Open entry', 'killerconversions' ) . '

' . esc_html__( 'No entries to show', 'killerconversions' ) . '

' . esc_html__( 'Clear filters to check other entries, or submit a test from your published form.', 'killerconversions' ) . '

' . esc_html__( 'Clear filters', 'killerconversions' ) . '
'; KCONV_Pro_Discovery::hint( 'review' ); echo '
'; } public static function detail( $id ) { if ( ! KCONV_Core::can( 'kconv_view_entries' ) ) { return; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Inbox/export/bulk queries use validated filters and entry scope; scalar values are prepared, IDs are normalized with absint and operation columns come from a fixed map. Counts and export snapshots deliberately read current private rows. $row = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM %i WHERE id=%d', KCONV_Core::table( 'entries' ), $id ), ARRAY_A ); if ( $wpdb->last_error ) { self::die_error( new WP_Error( 'storage', __( 'This entry could not be read. Please retry after storage is repaired.', 'killerconversions' ) ) ); } if ( $row && ! KCONV_Entry_Access::allowed( $row, 'view' ) ) { wp_die( esc_html__( 'You do not have access to this entry.', 'killerconversions' ), '', array( 'response' => 403 ) ); } if ( ! $row ) { echo '

' . esc_html__( 'Entry unavailable', 'killerconversions' ) . '

' . esc_html__( 'This entry was deleted or expired.', 'killerconversions' ) . '

' . esc_html__( 'Back to inbox', 'killerconversions' ) . '
'; return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only detail() navigation/filter input; values are normalized or allowlisted and escaped at output. No state change occurs from this read; mutations use separate capability/nonce guards. $identity = self::identity( $row ); $definitions = self::definitions( $row ); $context = self::return_context( wp_unslash( $_GET ), $row['form_id'] ); $back = add_query_arg( array_merge( $context, array( 'page' => 'kconv-entries' ) ), admin_url( 'admin.php' ) ); echo '
'; if ( is_callable( array( 'KCONV_Admin', 'navigation' ) ) ) { KCONV_Admin::navigation( 'entries' ); } echo '
'; echo ' ' . esc_html__( 'Back to inbox', 'killerconversions' ) . '
' . esc_html__( 'ENTRY #', 'killerconversions' ) . esc_html( $id ) . '

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

' . esc_html( $definitions['title'] ) . ' · ' . esc_html( self::display_time( $row['created_at'], $context['time_display'] ) ) . '

' . esc_html( $row['is_read'] ? __( 'Read', 'killerconversions' ) : __( 'Unread', 'killerconversions' ) ) . ( $row['starred'] ? esc_html__( ' · ★ Starred', 'killerconversions' ) : '' ) . '
'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only detail() navigation/filter input; values are normalized or allowlisted and escaped at output. No state change occurs from this read; mutations use separate capability/nonce guards. if ( isset( $_GET['updated'] ) && '1' === $_GET['updated'] ) { echo '

' . esc_html__( 'Entry changes saved.', 'killerconversions' ) . '

'; } $answer_values = (array) json_decode( $row['payload'], true ); $answer_count = count( array_diff_key( $answer_values, array( '__kconv_estimate' => true, '__kconv_document' => true ) ) ); /* translators: %s: Number of submitted answer fields, excluding generated estimate and document records. */ $answer_count_label = sprintf( _n( '%s field', '%s fields', $answer_count, 'killerconversions' ), number_format_i18n( $answer_count ) ); echo '

' . esc_html__( 'Submitted answers', 'killerconversions' ) . '

' . esc_html( $answer_count_label ) . '

' . esc_html( 'submission' === $definitions['source'] ? __( 'Field labels and types were saved with this submission.', 'killerconversions' ) : ( 'legacy-current-labels' === $definitions['source'] ? __( 'Legacy entry: original field definitions were not saved. Labels shown come from the current form where available.', 'killerconversions' ) : __( 'The saved field definitions are unavailable. Current form labels are shown where available; original answers are unchanged.', 'killerconversions' ) ) ) . '

'; foreach ( $answer_values as $key => $value ) { if ( '__kconv_document' === $key ) { continue; } if ( '__kconv_estimate' === $key && class_exists( 'KCONV_Saved_Estimates' ) ) { echo '
' . esc_html__( 'Saved estimate', 'killerconversions' ) . '
' . esc_html( KCONV_Saved_Estimates::text( $value ) ) . '
'; continue; } $field = isset( $definitions['fields'][ $key ] ) ? $definitions['fields'][ $key ] : array( 'label' => $key, 'type' => '' ); $display_value = 'submission' === $definitions['source'] && 'checkbox' === $field['type'] && in_array( $value, array( '1', '0', '' ), true ) ? ( '1' === $value ? __( 'Checked', 'killerconversions' ) : __( 'Not checked', 'killerconversions' ) ) : KCONV_Core::value_text( $value ); echo '
' . esc_html( $field['label'] ) . '' . esc_html( $key . ( $field['type'] ? ' · ' . $field['type'] : '' ) ) . '
' . esc_html( $display_value ) . '
'; } echo '
'; do_action( 'kconv_admin_entry_detail', $row ); echo '
'; } public static function bulk() { if ( ! KCONV_Core::can( 'kconv_view_entries' ) ) { wp_die( esc_html__( 'You do not have permission for this action.', 'killerconversions' ), '', array( 'response' => 403 ) ); } if ( ! isset( $_POST['_wpnonce'] ) || ! is_string( $_POST['_wpnonce'] ) ) { wp_die( esc_html__( 'You do not have permission for this action.', 'killerconversions' ), '', array( 'response' => 403 ) ); } $nonce = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ); if ( ! wp_verify_nonce( $nonce, 'kconv_entries_bulk' ) ) { wp_die( esc_html__( 'You do not have permission for this action.', 'killerconversions' ), '', array( 'response' => 403 ) ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- IDs are unslashed, restricted to scalar values and normalized with absint; return filters use the bounded filters() validator. $ids = isset( $_POST['ids'] ) && is_array( $_POST['ids'] ) ? array_values( array_unique( array_filter( array_map( 'absint', array_filter( wp_unslash( $_POST['ids'] ), 'is_scalar' ) ) ) ) ) : array(); if ( ! $ids || count( $ids ) > 100 ) { self::die_error( new WP_Error( 'selection', __( 'Select between 1 and 100 entries.', 'killerconversions' ) ) ); } $operation = isset( $_POST['operation'] ) && is_string( $_POST['operation'] ) ? sanitize_key( $_POST['operation'] ) : ''; if ( ! KCONV_Entry_Access::assert_ids( $ids, in_array( $operation, array( 'delete', 'export' ), true ) ? $operation : 'review' ) ) { wp_die( esc_html__( 'You do not have access to every selected entry.', 'killerconversions' ), '', array( 'response' => 403 ) ); } if ( 'delete' === $operation ) { if ( ! KCONV_Core::can( 'kconv_delete_entries' ) || ! isset( $_POST['confirm_delete'] ) || '1' !== $_POST['confirm_delete'] ) { self::die_error( new WP_Error( 'confirm', __( 'Deletion requires permission and explicit confirmation.', 'killerconversions' ) ) ); } if ( ! KCONV_Core::delete_entries( $ids, true ) ) { self::die_error( new WP_Error( 'storage', __( 'Entry deletion failed and was rolled back. Please repair storage and retry.', 'killerconversions' ) ) ); } } elseif ( 'export' === $operation ) { if ( ! KCONV_Core::can( 'kconv_export_entries' ) ) { wp_die( 'Forbidden', '', array( 'response' => 403 ) ); } self::export( array( 'layout' => 'wide' ), $ids ); } else { $result = self::update_workflow( $ids, $operation, isset( $_POST['assigned_to'] ) && is_scalar( $_POST['assigned_to'] ) ? absint( $_POST['assigned_to'] ) : 0 ); if ( is_wp_error( $result ) ) { self::die_error( $result ); } } // Return only to known inbox routes; submitted URLs are never trusted. $return_entry = isset( $_POST['return_entry'] ) && is_scalar( $_POST['return_entry'] ) ? absint( $_POST['return_entry'] ) : 0; if ( $return_entry && in_array( $return_entry, $ids, true ) && 'delete' !== $operation ) { $return = array( 'page' => 'kconv-entries', 'entry_id' => $return_entry, 'updated' => '1', 'return_filters' => self::return_context( wp_unslash( $_POST ) ) ); } else { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- IDs are unslashed, restricted to scalar values and normalized with absint; return filters use the bounded filters() validator. $return_filters = isset( $_POST['return_filters'] ) && is_array( $_POST['return_filters'] ) ? self::filters( wp_unslash( $_POST['return_filters'] ) ) : array(); $return = array_merge( is_wp_error( $return_filters ) ? array() : $return_filters, array( 'page' => 'kconv-entries', 'updated' => '1' ) ); } wp_safe_redirect( add_query_arg( $return, admin_url( 'admin.php' ) ) ); exit; } public static function update_workflow( $ids, $operation, $assignee = 0 ) { if ( ! KCONV_Core::can( 'kconv_review_entries' ) ) { return new WP_Error( 'forbidden', __( 'Lead review access required.', 'killerconversions' ) ); } $map = array( 'read' => array( 'is_read', 1 ), 'unread' => array( 'is_read', 0 ), 'star' => array( 'starred', 1 ), 'unstar' => array( 'starred', 0 ), 'assign' => array( 'assigned_to', $assignee ) ); if ( ! isset( $map[ $operation ] ) ) { return new WP_Error( 'operation', __( 'Choose a supported action.', 'killerconversions' ) ); } if ( 'assign' === $operation && $assignee && ! user_can( $assignee, 'kconv_view_entries' ) && ! user_can( $assignee, 'manage_options' ) ) { return new WP_Error( 'assignee', __( 'Assign entries only to a user who can view them.', 'killerconversions' ) ); } $ids = array_values( array_unique( array_filter( array_map( 'absint', (array) $ids ) ) ) ); if ( ! $ids || count( $ids ) > 100 ) { return new WP_Error( 'selection', __( 'Select 1–100 entries.', 'killerconversions' ) ); } if ( ! KCONV_Entry_Access::assert_ids( $ids, 'review' ) ) { return new WP_Error( 'forbidden', __( 'The selection is outside your review access.', 'killerconversions' ) ); } $override = apply_filters( 'kconv_entry_workflow_override', null, $ids, $operation, $assignee ); if ( null !== $override ) { return $override; } global $wpdb; list( $column, $value ) = $map[ $operation ]; $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- Inbox/export/bulk queries use validated filters and entry scope; scalar values are prepared, IDs are normalized with absint and operation columns come from a fixed map. Counts and export snapshots deliberately read current private rows. Inbox/export/bulk queries use validated filters and entry scope; scalar values are prepared, IDs are normalized with absint and operation columns come from a fixed map. Counts and export snapshots deliberately read current private rows. Generated placeholders match the bounded ID array; PHPCS cannot statically count the generated list. $ok = $wpdb->query( $wpdb->prepare( "UPDATE %i SET %i=%d WHERE id IN ($placeholders) AND " . KCONV_Entry_Access::where( '', 'review' ), array_merge( array( KCONV_Core::table( 'entries' ), $column, $value ), $ids ) ) ); return false === $ok ? new WP_Error( 'storage', __( 'The entry update failed. Please retry.', 'killerconversions' ) ) : true; } /** * Stage and verify the whole export before sending any download headers. * php://temp spills beyond 2 MiB to a private process-owned temporary file; * both temporary streams close on success/failure and no public file exists. */ public static function prepare_export( $input, $ids = array() ) { if ( ! KCONV_Core::can( 'kconv_export_entries' ) ) { return new WP_Error( 'forbidden', __( 'Entry export access required.', 'killerconversions' ) ); } $f = self::filters( $input ); if ( is_wp_error( $f ) ) { return $f; } $wide = ! isset( $input['layout'] ) || 'long' !== $input['layout']; global $wpdb; $table = KCONV_Core::table( 'entries' ); $where = self::where( $f ) . ' AND ' . KCONV_Entry_Access::where( '', 'export' ); if ( $ids && ! KCONV_Entry_Access::assert_ids( $ids, 'export' ) ) { return new WP_Error( 'forbidden', __( 'The selection is outside your export access.', 'killerconversions' ) ); } if ( $ids ) { $ids = array_values( array_unique( array_filter( array_map( 'absint', (array) $ids ) ) ) ); if ( ! $ids || count( $ids ) > 100 ) { return new WP_Error( 'selection', __( 'Select 1–100 entries.', 'killerconversions' ) ); } $where .= ' AND id IN (' . implode( ',', $ids ) . ')'; } $max_rows = max( 1, min( 100000, (int) apply_filters( 'kconv_export_max_rows', 100000 ) ) ); $max_bytes = max( 1024, min( 67108864, (int) apply_filters( 'kconv_export_max_bytes', 67108864 ) ) ); $deadline = microtime( true ) + 45; $source = false; $output = false; try { // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Inbox/export/bulk queries use validated filters and entry scope; scalar values are prepared, IDs are normalized with absint and operation columns come from a fixed map. Counts and export snapshots deliberately read current private rows. $selection = $wpdb->get_row( $wpdb->prepare( "SELECT MAX(id) AS ceiling,COUNT(*) AS expected FROM %i WHERE $where", $table ), ARRAY_A ); if ( $wpdb->last_error || ! is_array( $selection ) || ! isset( $selection['expected'] ) ) { throw new RuntimeException( __( 'Entry export could not read its selection. Repair storage and retry; no CSV was downloaded.', 'killerconversions' ) ); } $expected = (int) $selection['expected']; if ( $expected > $max_rows ) { throw new RuntimeException( __( 'This export has too many entries. Narrow the form or date filters and retry.', 'killerconversions' ) ); } $where .= $wpdb->prepare( ' AND id<=%d', (int) $selection['ceiling'] ); $source = fopen( 'php://temp/maxmemory:2097152', 'w+b' ); $output = fopen( 'php://temp/maxmemory:2097152', 'w+b' ); if ( ! is_resource( $source ) || ! is_resource( $output ) ) { throw new RuntimeException( __( 'Private export storage is unavailable. Nothing was downloaded.', 'killerconversions' ) ); } $columns = array(); $cursor = 0; $count = 0; do { // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Inbox/export/bulk queries use validated filters and entry scope; scalar values are prepared, IDs are normalized with absint and operation columns come from a fixed map. Counts and export snapshots deliberately read current private rows. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE $where AND id>%d ORDER BY id LIMIT 100", $table, $cursor ), ARRAY_A ); if ( $wpdb->last_error || ! is_array( $rows ) ) { throw new RuntimeException( __( 'Entry export stopped because a storage read failed. No partial CSV was downloaded; repair storage and retry.', 'killerconversions' ) ); } foreach ( $rows as $row ) { if ( microtime( true ) > $deadline || ++$count > $max_rows ) { throw new RuntimeException( __( 'This export exceeds the preparation limit. Narrow its filters and retry.', 'killerconversions' ) ); } $cursor = (int) $row['id']; $values = json_decode( $row['payload'], true ); if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $values ) ) { throw new RuntimeException( __( 'An entry contains unreadable stored answers. Repair it before exporting; no incomplete CSV was downloaded.', 'killerconversions' ) ); } $definitions = self::definitions( $row ); foreach ( $values as $key => $value ) { $label = isset( $definitions['fields'][ $key ] ) ? $definitions['fields'][ $key ]['label'] : $key; if ( ! isset( $columns[ $key ] ) ) { $columns[ $key ] = array( 'label' => $label, 'varied' => false ); } elseif ( $columns[ $key ]['label'] !== $label ) { $columns[ $key ]['varied'] = true; } } if ( $wide && count( $columns ) > 250 ) { throw new RuntimeException( __( 'This selection has more than 250 distinct fields. Filter one form or choose one row per field.', 'killerconversions' ) ); } $record = wp_json_encode( array( 'row' => $row, 'values' => $values, 'definitions' => $definitions ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Private bounded php://temp streams stage and verify the complete CSV before headers; these are process streams, not WP_Filesystem-managed site files. They close on both success and failure. if ( ! is_string( $record ) || strlen( $record ) + 1 > $max_bytes - ftell( $source ) || strlen( $record ) + 1 !== fwrite( $source, $record . "\n" ) ) { throw new RuntimeException( __( 'This export exceeds private temporary storage or could not be written. Narrow its filters and retry.', 'killerconversions' ) ); } } } while ( count( $rows ) === 100 ); if ( $count !== $expected ) { throw new RuntimeException( __( 'The entry selection changed while preparing the export. Please retry; no incomplete CSV was downloaded.', 'killerconversions' ) ); } if ( ! rewind( $source ) ) { throw new RuntimeException( __( 'Prepared entry data could not be reopened. Nothing was downloaded.', 'killerconversions' ) ); } ksort( $columns, SORT_STRING ); $base = array( 'Entry ID', 'Form ID', 'Submitted UTC', 'Email status', 'Read', 'Starred', 'Assigned user ID', 'Form title', 'Field definitions source', 'Form revision' ); $headers = array(); if ( $wide ) { foreach ( $columns as $key => $definition ) { $headers[] = KCONV_Admin::csv_value( $definition['varied'] ? $key . ' [labels vary; see field definitions]' : $definition['label'] . ' [' . $key . ']' ); } $headers[] = 'Field definitions (JSON)'; } else { $headers = array( 'Field ID', 'Field label', 'Field type', 'Value' ); } self::write_csv_row( $output, array_merge( $base, $headers ), $max_bytes ); $converted = 0; while ( false !== ( $line = fgets( $source ) ) ) { if ( microtime( true ) > $deadline ) { throw new RuntimeException( __( 'This export exceeds the preparation time limit. Narrow its filters and retry.', 'killerconversions' ) ); } $record = json_decode( $line, true ); if ( ! is_array( $record ) || ! isset( $record['row'], $record['values'], $record['definitions'] ) ) { throw new RuntimeException( __( 'Prepared export data could not be verified. Nothing was downloaded.', 'killerconversions' ) ); } $row = $record['row']; $values = $record['values']; $definitions = $record['definitions']; $converted++; $prefix = array( $row['id'], $row['form_id'], $row['created_at'], $row['mail_status'], $row['is_read'], $row['starred'], $row['assigned_to'], KCONV_Admin::csv_value( $definitions['title'] ), $definitions['source'], $definitions['revision'] ); if ( $wide ) { $data = $prefix; foreach ( $columns as $key => $unused ) { $data[] = isset( $values[ $key ] ) ? KCONV_Admin::csv_value( $values[ $key ] ) : ''; } $data[] = wp_json_encode( $definitions['fields'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); self::write_csv_row( $output, $data, $max_bytes ); } else { foreach ( $values as $key => $value ) { $definition = isset( $definitions['fields'][ $key ] ) ? $definitions['fields'][ $key ] : array( 'label' => $key, 'type' => '' ); self::write_csv_row( $output, array_merge( $prefix, array( KCONV_Admin::csv_value( $key ), KCONV_Admin::csv_value( $definition['label'] ), KCONV_Admin::csv_value( $definition['type'] ), KCONV_Admin::csv_value( $value ) ) ), $max_bytes ); } } } if ( ! feof( $source ) || $converted !== $count || ! fflush( $output ) ) { throw new RuntimeException( __( 'Export preparation did not complete. No partial CSV was downloaded.', 'killerconversions' ) ); } $bytes = ftell( $output ); if ( false === $bytes || ! rewind( $output ) ) { throw new RuntimeException( __( 'The completed CSV could not be verified. Nothing was downloaded.', 'killerconversions' ) ); } $hash = hash_init( 'sha256' ); $verified = hash_update_stream( $hash, $output ); if ( $verified !== $bytes || ! rewind( $output ) ) { throw new RuntimeException( __( 'The completed CSV could not be verified. Nothing was downloaded.', 'killerconversions' ) ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Private bounded php://temp streams stage and verify the complete CSV before headers; these are process streams, not WP_Filesystem-managed site files. They close on both success and failure. fclose( $source ); return array( 'stream' => $output, 'rows' => $count, 'bytes' => $bytes, 'sha256' => hash_final( $hash ) ); } catch ( Throwable $error ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Private bounded php://temp streams stage and verify the complete CSV before headers; these are process streams, not WP_Filesystem-managed site files. They close on both success and failure. if ( is_resource( $source ) ) { fclose( $source ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Private bounded php://temp streams stage and verify the complete CSV before headers; these are process streams, not WP_Filesystem-managed site files. They close on both success and failure. if ( is_resource( $output ) ) { fclose( $output ); } return new WP_Error( 'export_preparation', $error instanceof RuntimeException ? $error->getMessage() : __( 'Export preparation failed. No partial CSV was downloaded.', 'killerconversions' ) ); } } private static function write_csv_row( $stream, $row, $max_bytes ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- write_csv_row() throws a plain-text exception caught by prepare_export() and returned as WP_Error; die_error() escapes the message at its HTML output boundary. Escaping here would double-escape translations. if ( false === fputcsv( $stream, $row, ',', '"', '' ) || ftell( $stream ) > $max_bytes ) { throw new RuntimeException( __( 'The CSV exceeds the export size limit or could not be written. Narrow its filters and retry.', 'killerconversions' ) ); } } public static function export( $input, $ids = array() ) { $prepared = self::prepare_export( $input, $ids ); if ( is_wp_error( $prepared ) ) { self::die_error( $prepared ); } nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="killerconversions-entries-' . gmdate( 'Y-m-d' ) . '.csv"' ); header( 'X-Content-Type-Options: nosniff' ); header( 'Content-Length: ' . $prepared['bytes'] ); header( 'X-KillerConversions-Export-Rows: ' . $prepared['rows'] ); header( 'X-KillerConversions-Export-SHA256: ' . $prepared['sha256'] ); fpassthru( $prepared['stream'] ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Private bounded php://temp streams stage and verify the complete CSV before headers; these are process streams, not WP_Filesystem-managed site files. They close on both success and failure. fclose( $prepared['stream'] ); // Closing private temporary stream; no retained export artifact. exit; } public static function clone_form( $id ) { if ( ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'forbidden', __( 'Form administration required.', 'killerconversions' ) ); } // Extension operations hold their guard until all reads/writes and rollback complete. try { $allowed = apply_filters( 'kconv_clone_operation_allowed', true, $id ); 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::clone_configuration( $id ); } finally { do_action( 'kconv_clone_operation_finished', $id ); } } private static function clone_configuration( $id ) { if ( ! current_user_can( 'manage_options' ) ) { return new WP_Error( 'forbidden', __( 'Form administration required.', 'killerconversions' ) ); } $form = KCONV_Core::get_form( $id ); if ( ! $form ) { return new WP_Error( 'missing', __( 'Form unavailable.', 'killerconversions' ) ); } $new = wp_insert_post( array( 'post_type' => 'kconv_form', 'post_status' => 'draft', 'post_title' => $form['title'] . ' — copy' ), true ); if ( is_wp_error( $new ) ) { return $new; } // Templates/flow are reusable. Delivery consent, credentials, mail recipients and entries are not copied. $schema = array_intersect_key( $form, array_flip( array( 'fields', 'settings', 'estimate', 'steps', 'logic' ) ) ); $schema['settings']['notify_email'] = ''; update_post_meta( $new, '_kconv_schema', wp_slash( $schema ) ); $pro = get_post_meta( $id, '_kconv_pro', true ); if ( is_array( $pro ) ) { $flow = array( 'rules' => isset( $pro['rules'] ) ? $pro['rules'] : array(), 'steps' => isset( $pro['steps'] ) ? $pro['steps'] : array() ); update_post_meta( $new, '_kconv_pro', wp_slash( $flow ) ); } // Derive premium dependence from the copied design, not omitted delivery settings. // Ordinary Free steps remain Free; inactive premium pricing/logic stays paused. if ( get_post_meta( $new, '_kconv_schema', true ) !== $schema || ( is_array( $pro ) && get_post_meta( $new, '_kconv_pro', true ) !== $flow ) || ! KCONV_Free_Ownership::refresh_form( $new ) ) { wp_delete_post( $new, true ); return new WP_Error( 'storage', __( 'The complete form design could not be copied. Repair storage and try again.', 'killerconversions' ) ); } return $new; } public static function clone_handler() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission for this action.', 'killerconversions' ), '', array( 'response' => 403 ) ); } if ( ! isset( $_POST['_wpnonce'] ) || ! is_string( $_POST['_wpnonce'] ) ) { wp_die( esc_html__( 'You do not have permission for this action.', 'killerconversions' ), '', array( 'response' => 403 ) ); } $nonce = sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ); if ( ! wp_verify_nonce( $nonce, 'kconv_clone_form' ) ) { wp_die( esc_html__( 'You do not have permission for this action.', 'killerconversions' ), '', array( 'response' => 403 ) ); } $id = isset( $_POST['form_id'] ) && is_scalar( $_POST['form_id'] ) ? absint( $_POST['form_id'] ) : 0; $new = self::clone_form( $id ); if ( is_wp_error( $new ) ) { self::die_error( $new ); } wp_safe_redirect( admin_url( 'admin.php?page=killerconversions&edit=' . $new ) ); exit; } }