*/ public const EXPORT_COLUMNS = [ 'created_at', 'event_type', 'agent', 'url', 'post_id' ]; /** * How many rows are read per query while streaming the export. * * `Store::events()` clamps a page to 200 rows, so asking for more would * quietly return 200 and the loop would stop after the first page. * * @var int */ private const PAGE_SIZE = 200; /** * Largest export, in rows. * * @var int */ private const MAX_ROWS = 50000; /** * Highest page number the events route accepts. * * @var int */ private const PAGE_MAX = 100000; /** * Route the CSV filter answers for. * * @var string */ private const EXPORT_ROUTE = '/' . Controller::REST_NAMESPACE . '/citations/export'; /** * Argument schema of `GET /citations/stats`. * * @return array> Argument schemas. */ public static function stats_args(): array { return [ 'days' => Controller::int_arg( 30, 1, 365 ), ]; } /** * Argument schema of `GET /citations/events`. * * @return array> Argument schemas. */ public static function events_args(): array { return [ 'page' => Controller::int_arg( 1, 1, self::PAGE_MAX ), 'per_page' => Controller::int_arg( 50, 1, 200 ), 'type' => Controller::enum_arg( [ 'crawl', 'referral', '' ], '' ), ]; } /** * Argument schema of `DELETE /citations`. * * Without `older_than_days` the whole log goes, which is what a "delete * everything" button asks for; with it, only events older than that. * * @return array> Argument schemas. */ public static function purge_args(): array { return [ 'older_than_days' => Controller::int_arg( null, 1, 3650 ), ]; } /** * Aggregated statistics for the last `days` days. * * @param WP_REST_Request $request The request. * @return array Keys `totals`, `by_agent`, `top_pages` and `timeline`. */ public function stats( WP_REST_Request $request ): array { return Store::stats( (int) $request->get_param( 'days' ) ); } /** * One page of events, newest first. * * @param WP_REST_Request $request The request. * @return array Keys `rows` and `total`. */ public function events( WP_REST_Request $request ): array { $type = (string) $request->get_param( 'type' ); return Store::events( (int) $request->get_param( 'page' ), (int) $request->get_param( 'per_page' ), '' !== $type ? $type : null ); } /** * Deletes events. * * @param WP_REST_Request $request The request. * @return array Key `purged`. */ public function purge( WP_REST_Request $request ): array { $days = $request->get_param( 'older_than_days' ); return [ 'purged' => Store::purge( null === $days ? null : (int) $days ) ]; } /** * Hands the response over so the events can be sent as a CSV download. * * @return WP_REST_Response Empty response carrying the download headers. */ public function export(): WP_REST_Response { add_filter( 'rest_pre_serve_request', [ $this, 'serve_csv' ], 10, 3 ); $response = new WP_REST_Response( null, 200 ); $response->header( 'Content-Type', 'text/csv; charset=utf-8' ); $response->header( 'Content-Disposition', 'attachment; filename="' . self::EXPORT_FILENAME . '"' ); $response->header( 'X-Content-Type-Options', 'nosniff' ); $response->header( 'Cache-Control', 'no-store' ); return $response; } /** * Streams the event log as CSV instead of letting the server encode JSON. * * Public because WordPress invokes filter callbacks from outside the class. * * @param mixed $served Whether the request has already been served. * @param mixed $result The response the server was about to encode. * @param mixed $request The request being served. * @return bool True when this handler has sent the body itself. */ public function serve_csv( $served, $result, $request ): bool { // Another filter already wrote the body. Adding a second one to the // same response would corrupt both. if ( $served ) { return true; } if ( ! $request instanceof WP_REST_Request || self::EXPORT_ROUTE !== $request->get_route() ) { return false; } remove_filter( 'rest_pre_serve_request', [ $this, 'serve_csv' ], 10 ); $sent = 0; $page = 1; $read = 0; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV body sent with a fixed Content-Type; every cell is quoted and escaped by csv_row(), and HTML escaping would corrupt the file. echo self::csv_row( self::EXPORT_COLUMNS ); do { $events = Store::events( $page, self::PAGE_SIZE, null ); $rows = isset( $events['rows'] ) && is_array( $events['rows'] ) ? $events['rows'] : []; $read = count( $rows ); foreach ( $rows as $row ) { // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV body sent with a fixed Content-Type; every cell is quoted and escaped by csv_row(), and HTML escaping would corrupt the file. echo self::csv_row( self::columns_of( is_array( $row ) ? $row : [] ) ); ++$sent; if ( $sent >= self::MAX_ROWS ) { break 2; } } ++$page; } while ( self::PAGE_SIZE === $read ); return true; } /** * One CSV record, terminated with CRLF as RFC 4180 asks for. * * Pure: the same values always produce the same line. * * @param array $values Cell values, in column order. * @return string The record. */ public static function csv_row( array $values ): string { $cells = []; foreach ( $values as $value ) { $cells[] = self::csv_cell( is_scalar( $value ) ? (string) $value : '' ); } return implode( ',', $cells ) . "\r\n"; } /** * The exported columns of one event row. * * @param array $row Event row from `Citations\Store`. * @return array Values in column order. */ private static function columns_of( array $row ): array { $values = []; foreach ( self::EXPORT_COLUMNS as $column ) { $values[] = $row[ $column ] ?? ''; } return $values; } /** * One CSV cell: always quoted, never able to leave its field. * * Line breaks become spaces so a stored URL cannot split a record, and a * cell that starts with a spreadsheet's formula characters is prefixed with * an apostrophe — an agent name is data, and Excel opening a downloaded log * should not be able to execute it. * * @param string $value Cell value. * @return string Quoted cell. */ private static function csv_cell( string $value ): string { $value = str_replace( [ "\r\n", "\r", "\n" ], ' ', $value ); if ( '' !== $value && false !== strpos( "=+-@\t", $value[0] ) ) { $value = "'" . $value; } return '"' . str_replace( '"', '""', $value ) . '"'; } }