$request Request parameters ($_GET). */ public static function export_decision( array $request ): string { if ( ! isset( $request['ops_activity_log_network_export'] ) ) { return 'none'; } if ( ! is_multisite() || ! current_user_can( 'manage_network' ) ) { return 'forbidden'; } $nonce = isset( $request['_wpnonce'] ) && is_string( $request['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $request['_wpnonce'] ) ) : ''; if ( ! wp_verify_nonce( $nonce, self::EXPORT_NONCE ) ) { return 'bad_nonce'; } return 'export'; } /** * `admin_init` in the network admin: stream the export, or refuse it. */ public static function handle_export(): void { if ( ! is_network_admin() ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- export_decision() verifies the nonce. $request = $_GET; $decision = self::export_decision( $request ); if ( 'none' === $decision ) { return; } if ( 'export' !== $decision ) { wp_die( esc_html__( 'Security check failed.', '404ops-activity-log' ), '', array( 'response' => 403 ) ); } CsvExport::download_network( self::filters( $request ) ); } /** * The screen's filters, read from its request and validated: the site * screen's filters, plus `blog_id` (the `site` parameter: a site id, or 0 * for the network's own rows). * * @param array $request Request parameters. * @return array NetworkLog::get_events() filters. */ public static function filters( array $request ): array { $text = static function ( string $key ) use ( $request ): ?string { if ( ! isset( $request[ $key ] ) || ! is_string( $request[ $key ] ) ) { return null; } $value = sanitize_text_field( wp_unslash( $request[ $key ] ) ); return '' !== $value ? $value : null; }; $site = $text( 'site' ); $user = $text( 'user_id' ); $country = strtoupper( (string) $text( 'country' ) ); $device = (string) $text( 'device' ); return array( 'blog_id' => null !== $site && 1 === preg_match( '/^\d{1,19}$/D', $site ) ? (int) $site : null, 'user_id' => null !== $user && 1 === preg_match( '/^[1-9]\d{0,19}$/D', $user ) ? (int) $user : null, 'event_type' => null !== $text( 'event_type' ) ? sanitize_key( (string) $text( 'event_type' ) ) : null, 'date_from' => $text( 'date_from' ), 'date_to' => $text( 'date_to' ), 'search' => $text( 'log_search' ), 'country' => 1 === preg_match( '/^[A-Z]{2}$/', $country ) ? $country : null, 'device_class' => in_array( $device, UserAgent::CLASSES, true ) ? $device : null, ); } /** * Filters back into the screen's own query arguments, empty ones * dropped: for the pager and the export link. * * @param array $filters filters()'s answer. * @return array */ private static function query_args( array $filters ): array { $names = array( 'blog_id' => 'site', 'user_id' => 'user_id', 'event_type' => 'event_type', 'date_from' => 'date_from', 'date_to' => 'date_to', 'search' => 'log_search', 'country' => 'country', 'device_class' => 'device', ); $args = array(); foreach ( $names as $filter => $name ) { if ( isset( $filters[ $filter ] ) && '' !== (string) $filters[ $filter ] ) { $args[ $name ] = (string) $filters[ $filter ]; } } return $args; } /** * Every event type either log can hold, labelled. * * @return array */ public static function event_types(): array { return ActivityLog::get_event_types() + NetworkEvents::event_types(); } /** * Render the screen. */ public static function render(): void { if ( ! is_multisite() || ! current_user_can( 'manage_network' ) ) { return; } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- a read-only filter form. $request = $_GET; $page = isset( $request['paged'] ) && is_numeric( $request['paged'] ) ? max( 1, (int) $request['paged'] ) : 1; // phpcs:enable WordPress.Security.NonceVerification.Recommended $filters = self::filters( $request ); $events = NetworkLog::get_events( array_merge( $filters, array( 'per_page' => self::PER_PAGE, 'page' => $page, ) ) ); $total = NetworkLog::count_events( $filters ); $types = self::event_types(); $base = network_admin_url( 'admin.php?page=' . self::MENU_SLUG ); echo '
'; Render::header( __( 'Network Activity Log', '404ops-activity-log' ), __( 'Every site\'s events, and the network\'s own', '404ops-activity-log' ) ); echo '

' . esc_html__( 'Events are copied here as each site logs them, from when the plugin was network-activated. Earlier events are in each site\'s own log. Network events follow the main site\'s settings: its exclusions, IP address handling, location and device settings, and retention.', '404ops-activity-log' ) . '

'; self::render_filters( $filters, $types ); if ( array() === $events ) { Render::empty_state( __( 'No events found', '404ops-activity-log' ), __( 'Activity from every site will appear here.', '404ops-activity-log' ) ); echo '
'; return; } $sites = NetworkLog::site_labels( array_map( 'intval', array_column( $events, 'blog_id' ) ) ); echo ''; echo ''; foreach ( array( __( 'Timestamp', '404ops-activity-log' ), __( 'Site', '404ops-activity-log' ), __( 'User', '404ops-activity-log' ), __( 'IP Address', '404ops-activity-log' ), __( 'Location', '404ops-activity-log' ), __( 'Device', '404ops-activity-log' ), __( 'Event', '404ops-activity-log' ), __( 'Summary', '404ops-activity-log' ), __( 'Details', '404ops-activity-log' ), ) as $heading ) { echo ''; } echo ''; foreach ( $events as $event ) { $blog_id = (int) ( $event['blog_id'] ?? 0 ); $event_type = (string) ( $event['event_type'] ?? '' ); $label = $types[ $event_type ] ?? ucfirst( str_replace( '_', ' ', $event_type ) ); $ts = strtotime( (string) $event['timestamp'] ); $time = false !== $ts ? (string) wp_date( 'Y-m-d H:i:s', $ts ) : (string) $event['timestamp']; $email = (string) ( $event['user_email'] ?? '' ); $network = null === $event['site_event_id']; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html( $heading ) . '
' . esc_html( $time ) . '' . esc_html( $sites[ $blog_id ] ?? (string) $blog_id ); if ( $network ) { echo ' ' . esc_html__( 'Network', '404ops-activity-log' ) . ''; } echo '' . esc_html( '' !== $email ? $email : 'Guest' ) . '' . esc_html( (string) ( $event['ip_address'] ?? 'N/A' ) ) . '' . wp_kses_post( SettingsPage::location_cell( $event ) ) . '' . wp_kses_post( SettingsPage::device_cell( $event ) ) . '' . esc_html( $label ) . '' . esc_html( (string) $event['summary'] ) . '' . wp_kses_post( SettingsPage::render_details( self::without_revision_link( $event, $network ) ) ) . '
'; self::render_pager( $page, (int) ceil( $total / self::PER_PAGE ), $total, $base, $filters ); echo ''; } /** * A copied post row's revision link points into ITS site, and * render_details() builds it against the site the screen runs on; on * the network screen the object id is dropped so no link is built to the * wrong site's revision. * * @param array $event A network row. * @param bool $network Whether it is a network row. * @return array */ private static function without_revision_link( array $event, bool $network ): array { if ( ! $network ) { $event['object_id'] = null; } return $event; } /** * The filter form and the export link. * * @param array $filters filters()'s answer. * @param array $types Event types. */ private static function render_filters( array $filters, array $types ): void { echo '
'; echo ''; echo '
'; echo ''; echo ''; echo '
'; echo '
'; // Site: a list up to SITES_LISTED sites, a site-id field beyond. $blog_id = $filters['blog_id']; $sites = get_sites( array( 'number' => self::SITES_LISTED + 1, 'network_id' => get_current_network_id(), 'orderby' => 'id', ) ); echo '
'; echo ''; if ( count( $sites ) > self::SITES_LISTED ) { echo ''; } else { echo ''; } echo '
'; // User: network users, by login. echo '
'; echo ''; echo '
'; echo '
'; echo ''; echo '
'; echo '
'; echo ''; echo '
'; echo '
'; echo ''; echo '
'; echo '
'; echo ''; echo ''; echo '
'; echo '
'; echo ''; echo ''; echo '
'; echo '
'; submit_button( __( 'Filter', '404ops-activity-log' ), 'primary', 'ops_activity_log_filter', false ); $export = wp_nonce_url( add_query_arg( array_merge( self::query_args( $filters ), array( 'ops_activity_log_network_export' => '1' ) ), network_admin_url( 'admin.php?page=' . self::MENU_SLUG ) ), self::EXPORT_NONCE ); echo ' ' . esc_html__( 'Export CSV', '404ops-activity-log' ) . ''; echo '
'; } /** * Previous / next and where you are: a network log's page count has no * useful upper bound, so the pager never lists every page. * * @param int $page Current page. * @param int $pages Page count. * @param int $total Row count. * @param string $base Screen URL. * @param array $filters Filters. */ private static function render_pager( int $page, int $pages, int $total, string $base, array $filters ): void { echo '
'; /* translators: 1: page number, 2: page count, 3: event count. */ echo '' . esc_html( sprintf( __( 'Page %1$d of %2$d (%3$d events)', '404ops-activity-log' ), $page, max( 1, $pages ), $total ) ) . ' '; $args = self::query_args( $filters ); if ( $page > 1 ) { echo '' . esc_html__( 'Previous', '404ops-activity-log' ) . ' '; } if ( $page < $pages ) { echo '' . esc_html__( 'Next', '404ops-activity-log' ) . ''; } echo '
'; } }