| @@ -621,9 +621,9 @@ | ||
| 621 | 621 | $post->post_parent, |
| 622 | 622 | $post->menu_order, |
| 623 | 623 | $post->post_mime_type, |
| 624 | 624 | $post->comment_count, |
| 625 | - $this->get_term_ids( $post->ID, $post->post_type === 'betterdocs_faq' ? 'betterdocs_faq_category' : 'doc_category' ), | |
| 625 | + $this->get_term_ids( $post->ID, $post->post_type === 'betterdocs_faq' ? [ 'betterdocs_faq_category', 'betterdocs_product_faq_category' ] : 'doc_category' ), | |
| 626 | 626 | $this->get_term_ids( $post->ID, 'doc_tag' ), |
| 627 | 627 | $this->get_term_ids( $post->ID, 'knowledge_base' ), |
| 628 | 628 | $attachment_url ? $attachment_url : '', |
| 629 | 629 | $attachment_id ? $attachment_id : '', |
| @@ -635,13 +635,15 @@ | ||
| 635 | 635 | return $csv_data_posts; |
| 636 | 636 | } |
| 637 | 637 | |
| 638 | 638 | public function get_term_ids( $post_id, $taxonomy ) { |
| 639 | - $terms = get_the_terms( $post_id, $taxonomy ); | |
| 639 | + // Accept one or more taxonomies. FAQ posts can live in either the general | |
| 640 | + // (betterdocs_faq_category) or the Product FAQ (betterdocs_product_faq_category) | |
| 641 | + // taxonomy, so both are queried for the FAQ group column. | |
| 642 | + $term_ids = wp_get_object_terms( $post_id, (array) $taxonomy, [ 'fields' => 'ids' ] ); | |
| 640 | 643 | |
| 641 | - if ( $terms && ! is_wp_error( $terms ) ) { | |
| 642 | - $term_ids = wp_list_pluck( $terms, 'term_id' ); | |
| 643 | - return implode( ', ', $term_ids ); | |
| 644 | + if ( $term_ids && ! is_wp_error( $term_ids ) ) { | |
| 645 | + return implode( ', ', array_map( 'intval', $term_ids ) ); | |
| 644 | 646 | } |
| 645 | 647 | |
| 646 | 648 | return ''; |
| 647 | 649 | } |
| @@ -650,11 +652,14 @@ | ||
| 650 | 652 | ob_start(); |
| 651 | 653 | |
| 652 | 654 | $output = fopen( 'php://output', 'w' ); |
| 653 | 655 | |
| 654 | - // Add CSV rows | |
| 656 | + // Add CSV rows. Neutralize spreadsheet formula injection: a cell that a | |
| 657 | + // lower-privileged author controls (e.g. a doc/FAQ title or term name) could | |
| 658 | + // start with =, +, -, @, or a tab/CR and execute when the admin opens the | |
| 659 | + // export in Excel/LibreOffice. Prefix such cells with a single quote. | |
| 655 | 660 | foreach ( $data as $row ) { |
| 656 | - fputcsv( $output, $row ); | |
| 661 | + fputcsv( $output, array_map( [ $this, 'neutralize_csv_cell' ], (array) $row ) ); | |
| 657 | 662 | } |
| 658 | 663 | |
| 659 | 664 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing php://output stream; WP_Filesystem does not apply. |
| 660 | 665 | fclose( $output ); |
| @@ -659,6 +664,20 @@ | ||
| 659 | 664 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing php://output stream; WP_Filesystem does not apply. |
| 660 | 665 | fclose( $output ); |
| 661 | 666 | |
| 662 | 667 | return ob_get_clean(); |
| 668 | + } | |
| 669 | + | |
| 670 | + /** | |
| 671 | + * Prefix a leading formula trigger (= + - @ tab CR) with a single quote so | |
| 672 | + * spreadsheet apps treat the cell as text instead of executing it. | |
| 673 | + */ | |
| 674 | + private function neutralize_csv_cell( $cell ) { | |
| 675 | + $cell = (string) $cell; | |
| 676 | + | |
| 677 | + if ( $cell !== '' && preg_match( '/^[=+\-@\t\r]/', $cell ) ) { | |
| 678 | + return "'" . $cell; | |
| 679 | + } | |
| 680 | + | |
| 681 | + return $cell; | |
| 663 | 682 | } |
| 664 | 683 | } |