| @@ -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 | } |