report_recheck_verdicts( $from, $to ); $decisions = $repo->report_decision_statuses( $from, $to ); // Both event streams decide a figure on their own, so both have to // pull their case into the row query: a case rechecked or decided in // the window can have been observed again since, which moves its // `updated_at` out of the window entirely. $ids = []; foreach ( $rechecks as $row ) { $ids[] = (int) $row['case_id']; } foreach ( $decisions as $row ) { $ids[] = (int) $row['case_id']; } $cases = $repo->report_case_rows( $from, $to, $ids ); return GUARDFORGE_Guard_Report::build( $month, $cases, $rechecks, [ 'site' => home_url(), 'generated_at' => current_time( 'mysql' ), // The report is a reading of the case table as of the last time // sync ran, not a claim about this instant — the same honesty // GUARDFORGE_Cases::first_screen_summary owes its own card. 'data_as_of' => (string) get_option( GUARDFORGE_Cases::LAST_SYNC_OPTION, '' ), ], $decisions ); } /** * admin-post handler: capability, nonce, then the download. Built in * memory rather than streamed, unlike GUARDFORGE_Audit_Export: this file * is one header block, five figures and one row per case touched in a * single month — bounded by a month of activity, not by the size of the * log. */ public function handle_export() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Access denied.', 'guardforge' ) ); } check_admin_referer( self::NONCE ); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- checked above. $month = isset( $_POST['month'] ) ? sanitize_text_field( wp_unslash( $_POST['month'] ) ) : ''; $report = $this->month_report( $month ); if ( $report === null ) { $report = $this->month_report( $this->current_month() ); } nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename=guardforge-report-' . $report['month'] . '.csv' ); // Not markup and not a page: this is the body of a text/csv download // sent with Content-Disposition: attachment, and every field goes // through csv_field above. An HTML escape here would not protect a // reader — it would corrupt the file, turning quotes and ampersands // in the data into entities inside the spreadsheet. echo $this->csv_string( $report ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV body, not markup; every field comes out of GUARDFORGE_Guard_Report's own vocabularies. exit; } /** * The exact bytes the download consists of. A method of its own, and not * inlined into handle_export, because that is what lets the leak probe * (class-guard-report-redaction-test.php) run the whole report — the two * SELECTs, the redaction, the CSV writer — over poisoned rows and grep * the real output, instead of testing a hand-built copy of it. * * @param array $report GUARDFORGE_Guard_Report::build's shape. * @return string */ public function csv_string( array $report ) { // Assembled as a string rather than through fputcsv on a php://temp // handle: the result is the same bytes, and it needs no stream — the // directory's checks read every fopen as a filesystem write. $csv = ''; foreach ( GUARDFORGE_Guard_Report::csv_matrix( $report ) as $line ) { $csv .= implode( ',', array_map( [ __CLASS__, 'csv_field' ], (array) $line ) ) . "\n"; } return $csv; } /** * One field, quoted exactly when fputcsv( $h, $line, ',', '"', '' ) would * quote it — on a comma, a quote, a line break, a tab or a space — with * quotes doubled inside. Kept byte-for-byte with fputcsv so a spreadsheet * that opened last month's export opens this one the same way. * * @param mixed $value * @return string */ private static function csv_field( $value ) { $value = (string) $value; if ( strpbrk( $value, ",\"\n\r\t " ) === false ) { return $value; } return '"' . str_replace( '"', '""', $value ) . '"'; } }