PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/Admin/CSVExporter.php +50 -16 4.5.44.9.2 View file →
@@ -17,8 +17,9 @@
17 17 'end_date' => false,
18 18 'status' => false,
19 19 'offset' => 0,
20 20 'limit' => -1,
21 + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- exporter accepts user-defined meta filters by design.
21 22 'meta_query' => [], // If specified `meta_key` then will include all post(s) that have this meta_key.
22 23 'query_args' => []
23 24 ];
24 25
@@ -73,8 +74,11 @@
73 74 if ( ! in_array( $this->args['content'], $allowed_post_types ) ) {
74 75 return [];
75 76 }
76 77
78 + // $this->wpdb->posts and $this->wpdb->term_relationships are WP-provided table identifiers.
79 + // Dynamic %d placeholder lists are built to match the corresponding integer arrays.
80 + // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
77 81 $where = $this->wpdb->prepare(
78 82 "{$this->wpdb->posts}.post_type = %s",
79 83 $this->args['content']
80 84 );
@@ -88,9 +92,9 @@
88 92 $where .= " AND {$this->wpdb->posts}.post_status != 'auto-draft'";
89 93 }
90 94
91 95 if ( ! empty( $this->args['post__in'] ) ) {
92 - $post_in = $this->args['post__in'];
96 + $post_in = array_map( 'intval', $this->args['post__in'] );
93 97 $ids_placeholder = implode( ', ', array_fill( 0, count( $post_in ), '%d' ) );
94 98 $where .= $this->wpdb->prepare(
95 99 " AND {$this->wpdb->posts}.ID IN ($ids_placeholder)",
96 100 $post_in
@@ -106,9 +110,9 @@
106 110 // Handle doc categories
107 111 foreach ( $this->args['category_terms'] as $term_slug ) {
108 112 $term = get_term_by( 'slug', $term_slug, 'doc_category' );
109 113 if ( $term ) {
110 - $tax_terms[] = $term->term_taxonomy_id;
114 + $tax_terms[] = (int) $term->term_taxonomy_id;
111 115 }
112 116 }
113 117
114 118 if ( ! empty( $tax_terms ) ) {
@@ -120,16 +124,16 @@
120 124 }
121 125 } elseif ( isset( $this->args['kb_terms'] ) ) {
122 126 $join = "INNER JOIN {$this->wpdb->term_relationships} ON ({$this->wpdb->posts}.ID = {$this->wpdb->term_relationships}.object_id)";
123 127 $kb_terms = [];
124 -
128 +
125 129 foreach ( $this->args['kb_terms'] as $term_slug ) {
126 130 $term = get_term_by( 'slug', $term_slug, 'knowledge_base' );
127 131 if ( $term ) {
128 - $kb_terms[] = $term->term_taxonomy_id;
132 + $kb_terms[] = (int) $term->term_taxonomy_id;
129 133 }
130 134 }
131 -
135 +
132 136 if ( ! empty( $kb_terms ) ) {
133 137 $term_placeholder = implode( ', ', array_fill( 0, count( $kb_terms ), '%d' ) );
134 138 $where .= $this->wpdb->prepare(
135 139 " AND {$this->wpdb->term_relationships}.term_taxonomy_id IN ($term_placeholder)",
@@ -166,9 +170,10 @@
166 170 $join .= ' ' . $query_clauses['join'];
167 171 $where .= ' ' . $query_clauses['where'];
168 172 }
169 173
170 - // Get post IDs
174 + // $where and $join are composed from prepared fragments above; identifiers are WP-provided.
175 + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
171 176 $post_ids = $this->wpdb->get_col( "SELECT ID FROM {$this->wpdb->posts} $join WHERE $where" );
172 177
173 178 // Add FAQ post IDs if include_faq is true
174 179 if ( ! empty( $this->args['include_faq'] ) ) {
@@ -177,8 +182,9 @@
177 182 'post_type' => 'betterdocs_faq',
178 183 'posts_per_page' => -1,
179 184 'fields' => 'ids',
180 185 'post_status' => 'publish',
186 + // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- intentional: export the raw, untranslated FAQ set so multilingual filters don't drop or swap rows during export.
181 187 'suppress_filters' => true,
182 188 ]
183 189 );
184 190 $post_ids = array_merge( $post_ids, $faq_ids );
@@ -257,9 +263,9 @@
257 263 );
258 264 $csv_data_combined[] = $combined_row;
259 265 }
260 266
261 - $filename = 'betterdocs.' . date( 'Y-m-d' ) . '.csv';
267 + $filename = 'betterdocs.' . gmdate( 'Y-m-d' ) . '.csv';
262 268 $csv_content = $this->generate_csv( $csv_data_combined );
263 269
264 270 return [
265 271 'success' => true,
@@ -443,12 +449,20 @@
443 449 array_push( $glossary_term_ids, $term_object->term_id );
444 450 }
445 451 }
446 452 } else {
447 - $glossary_term_ids = $this->wpdb->get_col( "SELECT term_id from {$this->wpdb->term_taxonomy} where taxonomy='{$this->args['content']}';" );
453 + // $this->wpdb->term_taxonomy is a WP-core table identifier; %s placeholder binds taxonomy name.
454 + // phpcs:disable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
455 + $glossary_term_ids = $this->wpdb->get_col(
456 + $this->wpdb->prepare(
457 + "SELECT term_id FROM {$this->wpdb->term_taxonomy} WHERE taxonomy = %s",
458 + (string) $this->args['content']
459 + )
460 + );
461 + // phpcs:enable WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter
448 462 }
449 463
450 - $filename = 'betterdocs.' . date( 'Y-m-d' ) . '.csv';
464 + $filename = 'betterdocs.' . gmdate( 'Y-m-d' ) . '.csv';
451 465 $csv_data_combined = $this->get_glossaries_csv_data( $glossary_term_ids );
452 466 $csv_content = $this->generate_csv( $csv_data_combined );
453 467
454 468 return [
@@ -607,9 +621,9 @@
607 621 $post->post_parent,
608 622 $post->menu_order,
609 623 $post->post_mime_type,
610 624 $post->comment_count,
611 - $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' ),
612 626 $this->get_term_ids( $post->ID, 'doc_tag' ),
613 627 $this->get_term_ids( $post->ID, 'knowledge_base' ),
614 628 $attachment_url ? $attachment_url : '',
615 629 $attachment_id ? $attachment_id : '',
@@ -621,13 +635,15 @@
621 635 return $csv_data_posts;
622 636 }
623 637
624 638 public function get_term_ids( $post_id, $taxonomy ) {
625 - $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' ] );
626 643
627 - if ( $terms && ! is_wp_error( $terms ) ) {
628 - $term_ids = wp_list_pluck( $terms, 'term_id' );
629 - return implode( ', ', $term_ids );
644 + if ( $term_ids && ! is_wp_error( $term_ids ) ) {
645 + return implode( ', ', array_map( 'intval', $term_ids ) );
630 646 }
631 647
632 648 return '';
633 649 }
@@ -636,14 +652,32 @@
636 652 ob_start();
637 653
638 654 $output = fopen( 'php://output', 'w' );
639 655
640 - // 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.
641 660 foreach ( $data as $row ) {
642 - fputcsv( $output, $row );
661 + fputcsv( $output, array_map( [ $this, 'neutralize_csv_cell' ], (array) $row ) );
643 662 }
644 663
664 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closing php://output stream; WP_Filesystem does not apply.
645 665 fclose( $output );
646 666
647 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;
648 682 }
649 683 }