*/ private const COLUMNS = array( 'timestamp', 'user_email', 'ip_address', 'event_type', 'object_type', 'summary', 'details', 'country', 'city', 'device_class', 'browser', 'os' ); /** * Capability-checked entry point: emits download headers and streams the * CSV body, then ends the request. Not unit tested past the capability * gate — the header()/exit() pair only make sense against a real HTTP * response. * * @param array $filters Filter args (user_id, event_type, date_from, date_to). */ public static function download( array $filters ): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Unauthorized', '404ops-activity-log' ) ); } $filename = 'activity-log-' . gmdate( 'Y-m-d-His' ) . '.csv'; nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); self::stream( $filters ); exit; } /** * Writes the CSV (header line + every matching event) directly to the * active output buffer and returns the number of event rows written. * The real, testable core of the export — capture with ob_start() in * tests rather than calling download(), which ends the request. * * @param array $filters Filter args (user_id, event_type, date_from, date_to). * @return int Number of event rows written (excludes the header line). */ public static function stream( array $filters ): int { return self::stream_rows( $filters, false ); } /** * The network log's export (#8): super admins only, from the Network * Admin screen. The site's columns in the site's order, then `Site` and * `Site ID` APPENDED, so a reader of a site export's columns by position * reads a network export the same way. * * @param array $filters NetworkLog::get_events() filters (the site's plus `blog_id`). */ public static function download_network( array $filters ): void { if ( ! current_user_can( 'manage_network' ) ) { wp_die( esc_html__( 'Unauthorized', '404ops-activity-log' ) ); } $filename = 'network-activity-log-' . gmdate( 'Y-m-d-His' ) . '.csv'; nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); self::stream_network( $filters ); exit; } /** * download_network()'s testable core; see stream(). * * @param array $filters NetworkLog::get_events() filters. * @return int Rows written. */ public static function stream_network( array $filters ): int { return self::stream_rows( $filters, true ); } /** * @param array $filters Filters. * @param bool $network Whether this is the network log. */ private static function stream_rows( array $filters, bool $network ): int { $header = array( 'Timestamp', 'User', 'IP Address', 'Event Type', 'Object Type', 'Summary', 'Details', 'Country', 'City', 'Device', 'Browser', 'OS' ); if ( $network ) { $header[] = 'Site'; $header[] = 'Site ID'; } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV bytes, not HTML; every field is formula-guarded and quote-escaped by line(). echo self::line( $header ); $written = 0; $page = 1; do { $args = array_merge( $filters, array( 'per_page' => self::BATCH_SIZE, 'page' => $page, ) ); $events = $network ? NetworkLog::get_events( $args ) : ActivityLog::get_events( $args ); $sites = $network ? NetworkLog::site_labels( array_map( 'intval', array_column( $events, 'blog_id' ) ) ) : array(); $batch = ''; foreach ( $events as $event ) { $fields = self::row_fields( $event ); if ( $network ) { $blog_id = (int) ( $event['blog_id'] ?? 0 ); $fields[] = $sites[ $blog_id ] ?? ''; $fields[] = (string) $blog_id; } $batch .= self::line( $fields ); ++$written; } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV bytes, not HTML; every field is formula-guarded and quote-escaped by line(). echo $batch; $fetched = count( $events ); ++$page; } while ( self::BATCH_SIZE === $fetched ); return $written; } /** * One event's cell values, in COLUMNS order. * * @param array $event One ActivityLog::get_events() row. * @return array */ private static function row_fields( array $event ): array { $user_email = (string) ( $event['user_email'] ?? '' ); $fields = array(); foreach ( self::COLUMNS as $column ) { if ( 'user_email' === $column ) { $fields[] = '' !== $user_email ? $user_email : 'Guest'; continue; } $fields[] = (string) ( $event[ $column ] ?? '' ); } return $fields; } /** * One CSV line: every field formula-guarded and double-quote-escaped, * wrapped in double quotes, joined with commas, CRLF-terminated. * * Public since client reports (#11) because the Pro add-on's client-report CSV has its * own column set and must still run exactly THIS guard, not a copy of it * that could drift (the same reasoning that has `Reports\Mailer` capture * `stream()` rather than re-implement it). That makes it a published * seam: `ProSeamsTest` pins its name and behaviour from the free side. * * @param array $fields */ public static function line( array $fields ): string { $escaped = array_map( static function ( string $field ): string { $field = self::guard_formula( $field ); return '"' . str_replace( '"', '""', $field ) . '"'; }, $fields ); return implode( ',', $escaped ) . "\r\n"; } /** * Mark known spreadsheet-formula boundaries as text in this human-viewed * CSV, per OWASP's CSV Injection guidance. A cell is guarded if, after * stripping any leading run of whitespace and control bytes (ASCII * space, tab, CR, LF, NUL, and the rest of the \x00-\x1F C0 range, plus * Unicode spaces such as NBSP), the next character is one of the ASCII * trigger set `=`, `+`, `-`, `@`, `|`, `%`, or the locale-sensitive * full-width equivalent of `=`, `+`, `-` or `@` (`=`, `+`, `-`, `@` * — `|` and `%` have no standard full-width form). Separately, a cell * whose raw first byte is a tab or CR is guarded even when a run of * such whitespace is not immediately followed by another trigger * character: some importers treat a leading tab/CR itself as a trigger. * * `-` catches a plain negative number (e.g. `-5`): OWASP's guidance is * to neutralize it too, so this trades a numeric-typed negative value * for a text-typed one in the spreadsheet in exchange for closing that * boundary — see the readme FAQ. * * Every field is separately double-quoted and quote-escaped in line(), so * commas and quotes cannot create a second CSV cell. The leading apostrophe * is a deliberately narrow text-prefix tradeoff for exports intended for * human spreadsheet viewing; it is not a claim of universal safety after a * file is saved and reopened by every spreadsheet application. */ private static function guard_formula( string $value ): string { if ( '' === $value ) { return $value; } if ( in_array( $value[0], array( '=', '+', '-', '@', '|', '%', "\t", "\r", "\n" ), true ) ) { return "'" . $value; } // The /u branch handles a leading run of whitespace/control bytes // (Unicode-aware \s, plus the C0 controls \s does not cover) ending // in a trigger character or a full-width formula marker. \s already // matches tab/CR/LF, and the trigger alternation includes them too, // so a run such as " \t" (space then tab) still matches via // backtracking: the tab is consumed as the required trigger rather // than as skippable whitespace. The ASCII first-byte branch above // remains effective if a legacy database row contains malformed // UTF-8 (preg_match on invalid UTF-8 simply fails to match here). $unicode_formula_boundary = 1 === preg_match( '/\A(?:\s|[\x00-\x08\x0E-\x1F])*(?:[=+\-@|%\x09\x0A\x0D]|[\x{FF1D}\x{FF0B}\x{FF0D}\x{FF20}])/u', $value ); return $unicode_formula_boundary ? "'" . $value : $value; } }