|null $details Details. * @param int $blog_id The site the event concerns, or 0 for the network itself. * @param int|null $actor Who acted, when that is not the current user. * @return int|null The new row's id, or null when nothing was written. */ public static function log( string $event_type, string $summary, ?string $object_type = null, ?int $object_id = null, ?array $details = null, int $blog_id = 0, ?int $actor = null ): ?int { global $wpdb; if ( ! self::is_active() ) { return null; } $main = (int) get_main_site_id(); $switched = get_current_blog_id() !== $main; if ( $switched ) { switch_to_blog( $main ); } try { $row = EventLogger::build_row( $event_type, $summary, $object_type, $object_id, $details, $actor ); if ( null === $row ) { return null; } $row = array_merge( array( 'network_id' => self::network_id(), 'blog_id' => max( 0, $blog_id ), ), $row ); $formats = array_merge( array( '%d', '%d' ), EventLogger::row_formats() ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the plugin's own table. if ( false === $wpdb->insert( self::table(), $row, $formats ) ) { return null; } $id = (int) $wpdb->insert_id; Alerts::on_event_recorded( array_merge( array( 'id' => $id, 'timestamp' => gmdate( 'Y-m-d H:i:s' ), ), $row ) ); return $id; } finally { if ( $switched ) { restore_current_blog(); } } } /** * Copy one row of the current site's log into the network log. * * An `INSERT ... SELECT` from the site row itself, so the copy is exactly * what the site stored, database-filled timestamp included. `IGNORE` * against the `site_row` unique key makes a repeated copy a no-op. The * caller reads the site row's `insert_id` BEFORE calling this: the copy * replaces it with its own. * * @param int $site_event_id The site row's id. */ public static function mirror_site_row( int $site_event_id ): void { global $wpdb; if ( $site_event_id <= 0 || ! self::is_active() ) { return; } $columns = implode( ', ', self::copied_columns() ); $query = 'INSERT IGNORE INTO ' . self::table() . ' ( network_id, blog_id, site_event_id, ' . $columns . ' )' . ' SELECT %d, %d, id, ' . $columns . ' FROM ' . Database::get_table_name() . ' WHERE id = %d'; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery -- table and column names are the plugin's own constants. $wpdb->query( $wpdb->prepare( $query, self::network_id(), get_current_blog_id(), $site_event_id ) ); } /** * Apply an update of a site row to its copy (the eraser's redactions). * * @param int $site_event_id The site row's id. * @param array $fields Column => new value. */ public static function mirror_update( int $site_event_id, array $fields ): void { global $wpdb; if ( array() === $fields || ! self::is_active() ) { return; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the plugin's own table. $wpdb->update( self::table(), $fields, array( 'blog_id' => get_current_blog_id(), 'site_event_id' => $site_event_id, ) ); } /** * Update a network row by id (the network eraser's redactions). * * @param int $id Network row id. * @param array $fields Column => new value. */ public static function update_row( int $id, array $fields ): bool { global $wpdb; if ( array() === $fields ) { return false; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the plugin's own table. $result = $wpdb->update( self::table(), $fields, array( 'id' => $id, 'network_id' => self::network_id(), ) ); return false !== $result; } /** * The network half of a site's retention purge, called by * `ActivityLog::purge_old_events()` after it removed the site's own rows * older than `$cutoff`: * * - this site's copies go with the rows they copy; * - on the main site, whose settings network rows follow, network rows * older than the main site's cutoff, and the copies of sites that no * longer exist (a deleted site's table went with it; its copies stay as * the network's record of what happened there, until this). * * @param string $cutoff UTC datetime; older rows go. * @return int Network rows removed. */ public static function purge_with_site( string $cutoff ): int { global $wpdb; if ( ! self::is_active() ) { return 0; } $table = self::table(); $removed = 0; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery -- the plugin's own table. $removed += (int) $wpdb->query( $wpdb->prepare( "DELETE FROM $table WHERE blog_id = %d AND site_event_id IS NOT NULL AND timestamp < %s", get_current_blog_id(), $cutoff ) ); if ( is_main_site() ) { $removed += (int) $wpdb->query( $wpdb->prepare( "DELETE FROM $table WHERE network_id = %d AND timestamp < %s AND ( site_event_id IS NULL OR blog_id NOT IN ( SELECT blog_id FROM {$wpdb->blogs} ) )", self::network_id(), $cutoff ) ); } // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery return $removed; } /** * The WHERE clause for a network read: always this network, then the * site (`blog_id`, 0 for the network itself), then every filter the * site log has, validated by the site log's own `build_where()`. * * @param array $args Filter arguments; `blog_id` int|null plus ActivityLog's. * @return array{0: string, 1: array} */ private static function build_where( array $args ): array { list( $where, $params ) = ActivityLog::build_where( $args ); $scope = ' WHERE network_id = %d'; $prefix = array( self::network_id() ); $blog_id = $args['blog_id'] ?? null; if ( null !== $blog_id && '' !== $blog_id && is_numeric( $blog_id ) && (int) $blog_id >= 0 ) { $scope .= ' AND blog_id = %d'; $prefix[] = (int) $blog_id; } // ActivityLog's clause opens with " WHERE 1=1"; its conditions follow. $where = $scope . substr( $where, strlen( ' WHERE 1=1' ) ); return array( $where, array_merge( $prefix, $params ) ); } /** * @param array $args Filters. * @return array */ private static function with_defaults( array $args ): array { return wp_parse_args( $args, array( 'blog_id' => null, 'user_id' => null, 'user_email' => null, 'event_type' => null, 'object_type' => null, 'object_id' => null, 'date_from' => null, 'date_to' => null, 'search' => null, 'country' => null, 'device_class' => null, 'marker' => null, 'per_page' => 20, 'page' => 1, ) ); } /** * One page of the network log, newest first. * * @param array $args Filters, `per_page`, `page`. * @return array> */ public static function get_events( array $args = array() ): array { global $wpdb; $args = self::with_defaults( $args ); list( $where, $params ) = self::build_where( $args ); $per_page = max( 1, (int) $args['per_page'] ); $params[] = ( max( 1, (int) $args['page'] ) - 1 ) * $per_page; $params[] = $per_page; $query = 'SELECT * FROM ' . self::table() . $where . ' ORDER BY timestamp DESC, id DESC LIMIT %d, %d'; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery -- built from the plugin's own clauses; every value a placeholder. $rows = $wpdb->get_results( $wpdb->prepare( $query, $params ), ARRAY_A ); return is_array( $rows ) ? $rows : array(); } /** * How many rows match. * * @param array $args Filters. */ public static function count_events( array $args = array() ): int { global $wpdb; list( $where, $params ) = self::build_where( self::with_defaults( $args ) ); $query = 'SELECT COUNT(*) FROM ' . self::table() . $where; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery -- as get_events(). return (int) $wpdb->get_var( $wpdb->prepare( $query, $params ) ); } /** * The countries the network log holds rows from, for its filter. Read * from the `country` index. * * @return array */ public static function countries(): array { global $wpdb; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery -- the plugin's own table. $codes = $wpdb->get_col( $wpdb->prepare( 'SELECT DISTINCT country FROM ' . self::table() . ' WHERE network_id = %d AND country IS NOT NULL ORDER BY country LIMIT 250', self::network_id() ) ); return array_values( array_filter( array_map( 'strval', (array) $codes ), static fn( string $c ): bool => 1 === preg_match( '/^[A-Z]{2}$/', $c ) ) ); } /** * The person-match clause of the personal-data tools, over NETWORK rows * only: a copy is a site's row, found and redacted by that site's own * eraser (and mirrored by it). * * @param string $email Their email address. * @param int|null $user_id Their account, when it exists. * @return array{0: string, 1: array} */ private static function person_where( string $email, ?int $user_id ): array { $where = ' WHERE network_id = %d AND site_event_id IS NULL AND ( user_email = %s'; $params = array( self::network_id(), $email ); if ( null !== $user_id ) { $where .= " OR user_id = %d OR ( object_type = 'user' AND object_id = %d )"; $params[] = $user_id; $params[] = $user_id; } return array( $where . ' )', $params ); } /** * `ActivityLog::get_events_for_person()` over network rows. * * @param string $email Their email address. * @param int|null $user_id Their account. * @param int $per_page Rows per page. * @param int $page 1-based page. * @return array> */ public static function get_events_for_person( string $email, ?int $user_id, int $per_page, int $page ): array { global $wpdb; list( $where, $params ) = self::person_where( $email, $user_id ); $query = 'SELECT *, ( user_email = %s ) AS matched_email FROM ' . self::table() . $where . ' ORDER BY id ASC LIMIT %d, %d'; $params = array_merge( array( $email ), $params, array( ( max( 1, $page ) - 1 ) * $per_page, $per_page ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery -- every value a placeholder. $rows = $wpdb->get_results( $wpdb->prepare( $query, $params ), ARRAY_A ); return is_array( $rows ) ? $rows : array(); } /** * `ActivityLog::count_events_for_person()` over network rows. * * @param string $email Their email address. * @param int|null $user_id Their account. */ public static function count_events_for_person( string $email, ?int $user_id ): int { global $wpdb; list( $where, $params ) = self::person_where( $email, $user_id ); $query = 'SELECT COUNT(*) FROM ' . self::table() . $where; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery -- every value a placeholder. return (int) $wpdb->get_var( $wpdb->prepare( $query, $params ) ); } /** * How the screen and the CSV name each site a page of rows mentions: * domain and path, "Network" for 0, and the id with "(deleted)" for a * site that is gone. One `get_sites()` query per page, bounded by the * page's own rows -- never one per row, never every site. * * @param array $blog_ids Site ids. * @return array Site id => label. */ public static function site_labels( array $blog_ids ): array { $blog_ids = array_values( array_unique( array_map( 'intval', $blog_ids ) ) ); $labels = array(); $lookup = array_values( array_filter( $blog_ids, static fn( int $id ): bool => $id > 0 ) ); if ( array() !== $lookup ) { foreach ( get_sites( array( 'site__in' => $lookup, 'number' => count( $lookup ), 'network_id' => self::network_id(), ) ) as $site ) { $labels[ (int) $site->blog_id ] = NetworkEvents::site_label( $site ); } } foreach ( $blog_ids as $id ) { if ( 0 === $id ) { $labels[0] = __( 'Network', '404ops-activity-log' ); } elseif ( ! isset( $labels[ $id ] ) ) { /* translators: %d: a site id. */ $labels[ $id ] = sprintf( __( 'Site %d (deleted)', '404ops-activity-log' ), $id ); } } return $labels; } /** * The columns a copy takes from its site row: all but the site row's own * id, which becomes `site_event_id`. * * @return array */ private static function copied_columns(): array { return array( 'timestamp', 'user_id', 'user_email', 'ip_address', 'event_type', 'object_type', 'object_id', 'summary', 'details', 'import_source', 'import_id', 'country', 'city', 'device_class', 'browser', 'os' ); } }