PluginProbe
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz / 2.12.8
SureForms – Contact Form Builder, AI Forms, Payment Form, Survey & Quiz v2.12.8
2.12.8 2.12.7 2.12.6 2.12.5 2.12.4 2.12.3 2.12.2 2.12.1 2.12.0 2.11.1 2.11.0 2.10.1 2.10.0 2.9.1 2.9.0 2.8.2 2.8.1 2.7.0 2.7.1 2.8.0 trunk 0.0.10 0.0.11 0.0.12 0.0.13 All 98 releases
← All changes | inc/entries.php +109 -40 2.12.2 → 2.12.8 View file →
@@ -39,9 +39,9 @@
39 39 * Optional. An array of arguments to customize the query.
40 40 *
41 41 * @type int $form_id Form ID to filter entries. Default 0 (all forms).
42 42 * @type string $status Entry status: 'all', 'read', 'unread', 'trash'. Default 'all'.
43 - * @type string $search Search term to filter entries by entry ID. Default empty.
43 + * @type string $search Search term matching entry ID (numeric terms), form title, or submitted form data (3+ characters). Default empty.
44 44 * @type string $date_from Start date for filtering entries (YYYY-MM-DD format). Default empty.
45 45 * @type string $date_to End date for filtering entries (YYYY-MM-DD format). Default empty.
46 46 * @type string $orderby Column to order by. Default 'created_at'.
47 47 * @type string $order Sort direction: 'ASC' or 'DESC'. Default 'DESC'.
@@ -83,11 +83,39 @@
83 83
84 84 // Build where conditions.
85 85 $where_conditions = self::build_where_conditions( $args );
86 86
87 - // Get total count for pagination.
88 - $total = EntriesTable::get_instance()->get_total_count( $where_conditions );
87 + // Get total count for pagination. When a search term is active the WHERE includes
88 + // an unindexed form_data LIKE, so the COUNT is a full scan of the candidate rows —
89 + // and it re-runs on every pagination click for an answer that cannot change between
90 + // clicks. Cache it briefly (30s) keyed on the exact conditions; ≤30s staleness in a
91 + // pager total is harmless for an admin screen. Unsearched listings stay uncached so
92 + // totals reflect trash/delete/read mutations immediately.
93 + if ( ! empty( $args['search'] ) ) {
94 + // wp_json_encode() returns false on failure, and (string) false is '' — which
95 + // would make every search share md5('') and serve one search's total for all
96 + // others. Skip the cache entirely rather than key it ambiguously.
97 + $encoded_conditions = wp_json_encode( $where_conditions );
98 + $count_cache_key = is_string( $encoded_conditions )
99 + ? 'srfm_entries_search_count_' . md5( $encoded_conditions )
100 + : '';
101 + $cached_total = '' !== $count_cache_key ? get_transient( $count_cache_key ) : false;
89 102
103 + if ( is_numeric( $cached_total ) ) {
104 + $total = absint( $cached_total );
105 + } else {
106 + $total = EntriesTable::get_instance()->get_total_count( $where_conditions );
107 + // Honor the skip-on-encode-failure decision above: only cache when we have
108 + // an unambiguous key. Otherwise set_transient( '', … ) would write a single
109 + // global transient shared across all searches.
110 + if ( '' !== $count_cache_key ) {
111 + set_transient( $count_cache_key, $total, 30 );
112 + }
113 + }
114 + } else {
115 + $total = EntriesTable::get_instance()->get_total_count( $where_conditions );
116 + }
117 +
90 118 // Calculate offset.
91 119 $offset = ( absint( $args['page'] ) - 1 ) * absint( $args['per_page'] );
92 120
93 121 // Get entries.
@@ -119,9 +147,9 @@
119 147 'entries' => $entries,
120 148 'total' => $total,
121 149 'per_page' => absint( $args['per_page'] ),
122 150 'current_page' => absint( $args['page'] ),
123 - 'total_pages' => ceil( $total / absint( $args['per_page'] ) ),
151 + 'total_pages' => ceil( $total / max( 1, absint( $args['per_page'] ) ) ),
124 152 'emptyTrash' => 0 === $trash_count,
125 153 ];
126 154 }
127 155
@@ -500,8 +528,45 @@
500 528 ];
501 529 }
502 530
503 531 /**
532 + * Neutralize CSV formula/macro injection in an exported cell.
533 + *
534 + * Spreadsheet applications (Excel, Google Sheets, LibreOffice) interpret a
535 + * cell whose value begins with `=`, `+`, `-`, `@`, a tab, or a carriage
536 + * return as a formula and may execute it when an admin opens the export.
537 + * A submitter could store `=HYPERLINK(...)` or `=cmd|...` in a field and
538 + * have it run on the admin's machine. Prefixing such values with a single
539 + * quote forces the spreadsheet to treat them as literal text.
540 + *
541 + * Well-formed numbers (including negative and decimal values) are returned
542 + * unchanged so numeric columns remain numeric in the spreadsheet.
543 + *
544 + * Public so every CSV writer in the product can share one implementation rather
545 + * than carrying its own copy — SureForms Pro exports partial entries through a
546 + * separate writer and needs the same guard.
547 + *
548 + * @param string $value Cell value (already normalized for CSV).
549 + *
550 + * @since 2.10.0
551 + * @since 2.12.3 Promoted from private to public so other export writers can reuse it.
552 + * @return string Safe cell value.
553 + */
554 + public static function escape_csv_formula( $value ) {
555 + $value = Helper::get_string_value( $value );
556 +
557 + if ( '' === $value || is_numeric( $value ) ) {
558 + return $value;
559 + }
560 +
561 + if ( in_array( $value[0], [ '=', '+', '-', '@', "\t", "\r" ], true ) ) {
562 + return "'" . $value;
563 + }
564 +
565 + return $value;
566 + }
567 +
568 + /**
504 569 * Build where conditions for entry queries.
505 570 *
506 571 * @param array<string, int|string|array<int>> $args Query arguments.
507 572 *
@@ -579,10 +644,15 @@
579 644 $where_conditions[] = $date_conditions;
580 645 }
581 646 }
582 647
583 - // Filter by search (entry ID + form title).
584 - if ( ! empty( $args['search'] ) && is_string( $args['search'] ) ) {
648 + // Filter by search (entry ID + form title + submitted form data).
649 + // Use an explicit empty-string test rather than ! empty(): empty( '0' ) is true in
650 + // PHP, so searching "0" silently dropped the entire search group and returned every
651 + // entry while the UI still showed the term.
652 + if ( isset( $args['search'] ) && is_string( $args['search'] ) && '' !== $args['search'] ) {
653 + global $wpdb;
654 +
585 655 $search_term = sanitize_text_field( $args['search'] );
586 656 $search_group = [ 'RELATION' => 'OR' ];
587 657
588 658 // If numeric, match entry ID.
@@ -603,9 +673,31 @@
603 673 'value' => $matching_form_ids,
604 674 ];
605 675 }
606 676
607 - // Only add if we have search conditions, otherwise force empty result.
677 + // Match submitted form data. The form_data column stores plain JSON
678 + // (Helper::encode_json() uses JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
679 + // so a LIKE matches submitted values textually — including emails, URLs and
680 + // non-ASCII input. The query compiler (Base::prepare_where_clauses()) wraps
681 + // the value in "%...%" itself; esc_like() here neutralizes user-typed wildcard
682 + // characters ("%", "_") so they match literally.
683 + // Performance guard: the LIKE cannot use an index (full scan of the LONGTEXT
684 + // column within the other filters), so require at least 3 characters before
685 + // matching form data. Shorter terms would match almost every row anyway while
686 + // costing the most. Numeric terms are exempt above (exact, indexed ID lookup),
687 + // and form-title matching is a cheap separate posts query.
688 + if ( mb_strlen( $search_term ) >= 3 ) {
689 + $search_group[] = [
690 + 'key' => 'form_data',
691 + 'compare' => 'LIKE',
692 + 'value' => $wpdb->esc_like( $search_term ),
693 + ];
694 + }
695 +
696 + // Guard: a short non-numeric term that matches no form title produces no
697 + // usable condition — force an empty result instead of silently returning
698 + // every entry (an OR-group with no conditions would be dropped by the
699 + // query compiler).
608 700 if ( count( $search_group ) > 1 ) {
609 701 $where_conditions[] = $search_group;
610 702 } else {
611 703 $where_conditions[] = [
@@ -724,11 +816,20 @@
724 816 * @since 2.0.0
725 817 * @return void
726 818 */
727 819 private static function write_csv_header( $stream, $block_labels ) {
820 + // Labels are decoded out of stored form_data keys, so they are submitter-influenced
821 + // and need the same formula escaping as the data cells — see write_csv_rows().
822 + $labels = array_map(
823 + static function ( $label ) {
824 + return self::escape_csv_formula( Helper::get_string_value( $label ) );
825 + },
826 + array_values( $block_labels )
827 + );
828 +
728 829 $header = array_merge(
729 830 [ __( 'Entry ID', 'sureforms' ), __( 'Date', 'sureforms' ), __( 'Status', 'sureforms' ) ],
730 - array_values( $block_labels )
831 + $labels
731 832 );
732 833 fputcsv( $stream, $header ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fputcsv
733 834 }
734 835
@@ -816,38 +917,6 @@
816 917 return sanitize_textarea_field( Helper::get_string_value( $field_value ) );
817 918 }
818 919
819 920 return sanitize_text_field( Helper::get_string_value( $field_value ) );
820 - }
821 -
822 - /**
823 - * Neutralize CSV formula/macro injection in an exported cell.
824 - *
825 - * Spreadsheet applications (Excel, Google Sheets, LibreOffice) interpret a
826 - * cell whose value begins with `=`, `+`, `-`, `@`, a tab, or a carriage
827 - * return as a formula and may execute it when an admin opens the export.
828 - * A submitter could store `=HYPERLINK(...)` or `=cmd|...` in a field and
829 - * have it run on the admin's machine. Prefixing such values with a single
830 - * quote forces the spreadsheet to treat them as literal text.
831 - *
832 - * Well-formed numbers (including negative and decimal values) are returned
833 - * unchanged so numeric columns remain numeric in the spreadsheet.
834 - *
835 - * @param string $value Cell value (already normalized for CSV).
836 - *
837 - * @since 2.10.0
838 - * @return string Safe cell value.
839 - */
840 - private static function escape_csv_formula( $value ) {
841 - $value = Helper::get_string_value( $value );
842 -
843 - if ( '' === $value || is_numeric( $value ) ) {
844 - return $value;
845 - }
846 -
847 - if ( in_array( $value[0], [ '=', '+', '-', '@', "\t", "\r" ], true ) ) {
848 - return "'" . $value;
849 - }
850 -
851 - return $value;
852 921 }
853 922 }