403]); } try { $input = []; foreach (['optin_id', 'identifier', 'search', 'purpose', 'lead_id', 'from', 'to', 'snapshot', 'group_identifier'] as $key) { // Nonce verified by check_admin_referer() above. A non-string // stays a non-string, so LeadQuery still refuses it as invalid. if (isset($_POST[$key])) $input[$key] = is_string($_POST[$key]) ? sanitize_text_field(wp_unslash($_POST[$key])) : []; } $query = LeadQuery::fromInput($input); } catch (InvalidArgumentException $invalid) { wp_die(esc_html($invalid->getMessage()), '', ['response' => 400]); } nocache_headers(); header('Content-Type: text/csv; charset=utf-8'); $questions = isset($_POST['format']) && is_string($_POST['format']) && sanitize_key(wp_unslash($_POST['format'])) === 'questions'; header('Content-Disposition: attachment; filename="' . ($questions ? 'wconvert-question-answers.csv' : self::filename()) . '"'); // `php://output` is the RESPONSE, not a file, which is the whole // point: the log can be large and the export streams it in keyset // batches, so nothing holds the whole CSV in memory. WP_Filesystem is // an abstraction over files on disk and cannot open this stream at // all — the alternative it implies is buffering the export and // writing it somewhere, which is the failure mode this shape exists // to avoid. // // WPCS agrees about the OPEN and only about the open: its // AlternativeFunctions sniff exempts fopen(), file_put_contents() and // readfile() when the filename is a local data stream, and // `php://output` is on that list. fclose() takes a handle rather than // a filename, so the sniff has nothing to inspect and flags every // call — including the one closing a handle it just exempted. $handle = fopen('php://output', 'w'); if ($handle !== false) { $this->stream($handle, $query->optinId, $query, $questions); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- closes the php://output handle opened above, which the same sniff exempts. fclose($handle); } exit; } /** * Write the whole log to a stream, in batches. * * @param resource $handle */ public function stream($handle, ?string $optinId, ?LeadQuery $query = null, bool $questions = false): void { // Read once, before the walk. Every batch labels its Leads from this, // and it includes soft-deleted Optins because their names are exactly // what the export has to carry (ADR 0002, ADR 0020). $names = $this->optins->names(); if ($questions) $this->csv->writeQuestionHeader($handle); else $this->csv->writeHeader($handle); // The empty string sorts below every ULID, so the first batch starts at // the beginning without a branch for it. $cursor = ''; $query ??= new LeadQuery(optinId: $optinId, snapshot: LeadQuery::snapshotNow()); while (true) { $batch = $this->leads->since($optinId, $cursor, self::BATCH, $query); if ($batch === []) { return; } if ($questions) $this->csv->writeQuestionRows($handle, $batch, $names); else $this->csv->writeRows($handle, $batch, $names); $cursor = $batch[count($batch) - 1]->id; } } /** * A filename carrying the day it was taken. * * Deliberately not the Optin's name: it is user-supplied text on its way * into a `Content-Disposition` header, and a date needs no escaping to be * safe. The Optin's name is in the file, on every row (ADR 0018). */ private static function filename(): string { return 'wconvert-leads-' . gmdate('Y-m-d') . '.csv'; } }