what the screen * calls them. `site_admins` is not here: super-admin grants have their * own rows, and watching the option too would log every grant twice. */ public const WATCHED_SETTINGS = array( 'site_name' => 'Network Title', 'admin_email' => 'Network Admin Email', 'new_admin_email' => 'Network Admin Email (awaiting confirmation)', 'registration' => 'Allow new registrations', 'registrationnotification' => 'Registration notification', 'add_new_users' => 'Add New Users', 'illegal_names' => 'Banned Names', 'limited_email_domains' => 'Limited Email Registrations', 'banned_email_domains' => 'Banned Email Domains', 'welcome_email' => 'Welcome Email', 'welcome_user_email' => 'Welcome User Email', 'first_post' => 'First Post', 'first_page' => 'First Page', 'first_comment' => 'First Comment', 'first_comment_author' => 'First Comment Author', 'first_comment_email' => 'First Comment Email', 'first_comment_url' => 'First Comment URL', 'upload_space_check_disabled' => 'Site upload space check disabled', 'blog_upload_space' => 'Site upload space (MB)', 'upload_filetypes' => 'Upload file types', 'fileupload_maxk' => 'Max upload file size (KB)', 'WPLANG' => 'Default Language', 'menu_items' => 'Enable administration menus', ); /** * Settings whose values are never stored, anywhere: email addresses are * personal data, and a network row has no site eraser to redact them * later. EventLogger::is_secret_setting() withholds anything named like * a credential on top, as it does for a site's settings. */ public const SETTINGS_WITHOUT_VALUES = array( 'admin_email', 'new_admin_email', 'first_comment_email' ); /** * Settings whose values are long text someone wrote (email and content * templates): the summary names the setting only, and details keep a * bounded before/after. */ public const LONG_TEXT_SETTINGS = array( 'welcome_email', 'welcome_user_email', 'first_post', 'first_page', 'first_comment' ); /** Characters of a long-text value details keep. */ private const LONG_TEXT_LIMIT = 200; /** Themes one row names before "and N more". */ private const THEMES_LISTED = 20; /** * Sites being created or deleted this request, whose own user changes * are part of that one action. site id => true. * * @var array */ private static array $sites_in_flux = array(); /** Whether a network user deletion is in progress (its per-site removals are part of it). */ private static bool $deleting_user = false; /** * The network-level event types, for the network screen's filter. Not * in ActivityLog::get_event_types(): no site's own log ever holds them. * * @return array */ public static function event_types(): array { return array( 'site_created' => 'Site Created', 'site_deleted' => 'Site Deleted', 'site_archived' => 'Site Archived', 'site_unarchived' => 'Site Unarchived', 'site_marked_spam' => 'Site Marked as Spam', 'site_unmarked_spam' => 'Site Not Spam', 'site_deactivated' => 'Site Deactivated', 'site_reactivated' => 'Site Reactivated', 'super_admin_granted' => 'Super Admin Granted', 'super_admin_revoked' => 'Super Admin Revoked', 'network_theme_enabled' => 'Network Theme Enabled', 'network_theme_disabled' => 'Network Theme Disabled', 'network_setting_changed' => 'Network Setting Changed', 'user_added_to_site' => 'User Added to Site', 'user_removed_from_site' => 'User Removed from Site', ); } /** * Wire the listeners. Idempotent: `add_action()` of the same static * callback twice keeps one. */ public static function register(): void { add_action( 'wp_initialize_site', array( __CLASS__, 'site_initializing' ), 1 ); add_action( 'wp_initialize_site', array( __CLASS__, 'site_created' ), 100 ); add_action( 'wp_uninitialize_site', array( __CLASS__, 'site_uninitializing' ), 1 ); add_action( 'wp_delete_site', array( __CLASS__, 'site_deleted' ) ); add_action( 'wp_update_site', array( __CLASS__, 'site_updated' ), 10, 2 ); add_action( 'granted_super_admin', array( __CLASS__, 'super_admin_granted' ) ); add_action( 'revoked_super_admin', array( __CLASS__, 'super_admin_revoked' ) ); add_action( 'add_site_option_allowedthemes', array( __CLASS__, 'themes_first_set' ), 10, 2 ); add_action( 'update_site_option_allowedthemes', array( __CLASS__, 'themes_changed' ), 10, 3 ); add_action( 'add_site_option', array( __CLASS__, 'setting_first_set' ), 10, 2 ); add_action( 'update_site_option', array( __CLASS__, 'setting_changed' ), 10, 3 ); add_action( 'add_user_to_blog', array( __CLASS__, 'user_added' ), 10, 3 ); add_action( 'remove_user_from_blog', array( __CLASS__, 'user_removed' ), 10, 2 ); add_action( 'wpmu_delete_user', array( __CLASS__, 'user_deleting' ), 10, 2 ); add_action( 'deleted_user', array( __CLASS__, 'user_deleted' ) ); // A deleted site's own log goes with its other tables. Before #8 it // was left behind: core drops only the tables this filter names. add_filter( 'wpmu_drop_tables', array( __CLASS__, 'drop_site_table' ), 10, 2 ); } /** * A site as the log names it: domain and path, from the row core already * holds -- no switch, no options read. * * @param \WP_Site $site The site. */ public static function site_label( \WP_Site $site ): string { return untrailingslashit( $site->domain . $site->path ); } /** * @param \WP_Site $site The site being created. */ public static function site_initializing( \WP_Site $site ): void { self::$sites_in_flux[ (int) $site->blog_id ] = true; } /** * After core's own initialisation (priority 10): the site's tables and * its administrator exist. Its activity-log table is created now too, * so its first events have somewhere to go before its first request. * * @param \WP_Site $site The new site. */ public static function site_created( \WP_Site $site ): void { $blog_id = (int) $site->blog_id; unset( self::$sites_in_flux[ $blog_id ] ); if ( ! NetworkLog::is_active() ) { return; } switch_to_blog( $blog_id ); try { Database::maybe_upgrade(); } finally { restore_current_blog(); } NetworkLog::log( 'site_created', sprintf( 'Site %s created', self::site_label( $site ) ), 'site', $blog_id, array( 'domain' => (string) $site->domain, 'path' => (string) $site->path, ), $blog_id ); } /** * @param \WP_Site $site The site being deleted. */ public static function site_uninitializing( \WP_Site $site ): void { self::$sites_in_flux[ (int) $site->blog_id ] = true; } /** * @param \WP_Site $site The deleted site. */ public static function site_deleted( \WP_Site $site ): void { $blog_id = (int) $site->blog_id; unset( self::$sites_in_flux[ $blog_id ] ); NetworkLog::log( 'site_deleted', sprintf( 'Site %s deleted', self::site_label( $site ) ), 'site', $blog_id, array( 'domain' => (string) $site->domain, 'path' => (string) $site->path, ), $blog_id ); } /** * A site's status flags, each change one row. `wp_update_site` also * fires for every `last_updated` bump (each published post), which * changes no flag and writes nothing. * * @param \WP_Site $new_site After. * @param \WP_Site $old_site Before. */ public static function site_updated( \WP_Site $new_site, \WP_Site $old_site ): void { $flags = array( 'archived' => array( 'site_archived', 'site_unarchived', 'archived', 'unarchived' ), 'spam' => array( 'site_marked_spam', 'site_unmarked_spam', 'marked as spam', 'marked as not spam' ), 'deleted' => array( 'site_deactivated', 'site_reactivated', 'deactivated', 'reactivated' ), ); foreach ( $flags as $flag => $names ) { $before = (int) $old_site->{$flag}; $after = (int) $new_site->{$flag}; if ( $before === $after ) { continue; } $on = 1 === $after; NetworkLog::log( $on ? $names[0] : $names[1], sprintf( 'Site %1$s %2$s', self::site_label( $new_site ), $on ? $names[2] : $names[3] ), 'site', (int) $new_site->blog_id, null, (int) $new_site->blog_id ); } } /** * @param int $user_id The new super admin. */ public static function super_admin_granted( $user_id ): void { self::super_admin_row( (int) $user_id, true ); } /** * @param int $user_id The former super admin. */ public static function super_admin_revoked( $user_id ): void { self::super_admin_row( (int) $user_id, false ); } /** * @param int $user_id The user. * @param bool $granted Granted or revoked. */ private static function super_admin_row( int $user_id, bool $granted ): void { $user = get_userdata( $user_id ); $login = $user ? (string) $user->user_login : (string) $user_id; NetworkLog::log( $granted ? 'super_admin_granted' : 'super_admin_revoked', $granted ? sprintf( 'Super admin granted to %s', $login ) : sprintf( 'Super admin revoked from %s', $login ), 'user', $user_id ); } /** * @param string $option Option name. * @param mixed $value The first allowed-themes list. */ public static function themes_first_set( $option, $value ): void { self::themes_changed( $option, $value, array() ); } /** * Network-enabled themes: `allowedthemes` is `stylesheet => true`. One * row per save and direction, naming the themes. * * @param string $option Option name. * @param mixed $value After. * @param mixed $old_value Before. */ public static function themes_changed( $option, $value, $old_value ): void { $after = self::enabled_themes( $value ); $before = self::enabled_themes( $old_value ); foreach ( array( 'network_theme_enabled' => array_values( array_diff( $after, $before ) ), 'network_theme_disabled' => array_values( array_diff( $before, $after ) ), ) as $type => $themes ) { if ( array() === $themes ) { continue; } $listed = array_slice( $themes, 0, self::THEMES_LISTED ); $names = array_map( array( __CLASS__, 'theme_name' ), $listed ); $more = count( $themes ) - count( $listed ); NetworkLog::log( $type, sprintf( 'network_theme_enabled' === $type ? 'Theme %s enabled for the network' : 'Theme %s disabled for the network', '"' . implode( '", "', $names ) . '"' . ( $more > 0 ? sprintf( ' and %d more', $more ) : '' ) ), 'theme', null, array( 'themes' => $listed ) + ( $more > 0 ? array( 'more' => $more ) : array() ) ); } } /** * A theme's name, or its directory when it has none (or is gone). * * @param string $stylesheet Theme directory. */ private static function theme_name( string $stylesheet ): string { $name = (string) wp_get_theme( $stylesheet )->get( 'Name' ); return '' !== $name ? $name : $stylesheet; } /** * @param mixed $value An allowedthemes value. * @return array The enabled stylesheets. */ private static function enabled_themes( $value ): array { if ( ! is_array( $value ) ) { return array(); } return array_map( 'strval', array_keys( array_filter( $value ) ) ); } /** * A watched network setting stored for the first time. * * @param string $option Option name. * @param mixed $value Its value. */ public static function setting_first_set( $option, $value ): void { if ( '' === $value || array() === $value || null === $value ) { return; } self::setting_changed( $option, $value, null ); } /** * A watched network setting changed. core's Network Settings screen * saves every field on every save, so an unchanged field (`'1'` over * `1`) is compared and dropped here. * * @param string $option Option name. * @param mixed $value After. * @param mixed $old_value Before. */ public static function setting_changed( $option, $value, $old_value ): void { $option = (string) $option; if ( ! isset( self::WATCHED_SETTINGS[ $option ] ) || self::same_value( $old_value, $value ) ) { return; } $label = self::WATCHED_SETTINGS[ $option ]; $withheld = in_array( $option, self::SETTINGS_WITHOUT_VALUES, true ) || EventLogger::is_secret_setting( $option ); if ( $withheld ) { NetworkLog::log( 'network_setting_changed', sprintf( 'Network setting "%s" updated', $label ), 'setting', null, array( 'option' => $option, 'values_withheld' => true, ) ); return; } $long = in_array( $option, self::LONG_TEXT_SETTINGS, true ); $scalars = ( null === $old_value || is_scalar( $old_value ) ) && is_scalar( $value ); $summary = ! $long && $scalars ? sprintf( 'Network setting "%1$s" changed from "%2$s" to "%3$s"', $label, EventLogger::bounded( (string) $old_value, 100 ), EventLogger::bounded( (string) $value, 100 ) ) : sprintf( 'Network setting "%s" updated', $label ); NetworkLog::log( 'network_setting_changed', $summary, 'setting', null, array( 'option' => $option, 'old_value' => self::detail_value( $old_value, $long ), 'new_value' => self::detail_value( $value, $long ), ) ); } /** * @param mixed $before Before. * @param mixed $after After. */ private static function same_value( $before, $after ): bool { $scalar = static fn( $v ): bool => null === $v || is_scalar( $v ); if ( $scalar( $before ) && $scalar( $after ) ) { return (string) $before === (string) $after; } return $before == $after; // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual -- '1' in a stored list against 1 posted is the same list. } /** * A value as details keep it: long text bounded, lists as they are. * * @param mixed $value The value. * @param bool $long Whether it is a long-text setting. * @return mixed */ private static function detail_value( $value, bool $long ) { if ( $long && is_scalar( $value ) ) { return EventLogger::bounded( (string) $value, self::LONG_TEXT_LIMIT ); } return $value; } /** * A user added to a site: by a super admin, by the site's own * administrator (Users → Add Existing), or by signup. Not while the site * is being created, whose administrator is part of `site_created`. * * @param int $user_id The user. * @param string $role Their role there. * @param int $blog_id The site. */ public static function user_added( $user_id, $role, $blog_id ): void { $blog_id = (int) $blog_id; if ( isset( self::$sites_in_flux[ $blog_id ] ) ) { return; } $user = get_userdata( (int) $user_id ); $site = get_site( $blog_id ); NetworkLog::log( 'user_added_to_site', sprintf( 'User %1$s added to site %2$s as %3$s', $user ? (string) $user->user_login : (string) $user_id, $site ? self::site_label( $site ) : (string) $blog_id, (string) $role ), 'user', (int) $user_id, array( 'role' => (string) $role ), $blog_id ); } /** * A user removed from a site. Not while the site is being deleted, nor * while the user is being deleted from the network: each of those is * one row of its own. * * @param int $user_id The user. * @param int $blog_id The site. */ public static function user_removed( $user_id, $blog_id ): void { $blog_id = (int) $blog_id; if ( self::$deleting_user || isset( self::$sites_in_flux[ $blog_id ] ) ) { return; } $user = get_userdata( (int) $user_id ); $site = get_site( $blog_id ); NetworkLog::log( 'user_removed_from_site', sprintf( 'User %1$s removed from site %2$s', $user ? (string) $user->user_login : (string) $user_id, $site ? self::site_label( $site ) : (string) $blog_id ), 'user', (int) $user_id, null, $blog_id ); } /** * A network user deletion begins (`wpmu_delete_user()`): logged now, * while the account can still be named, and its per-site removals are * part of it. Core fires no `delete_user` for it, so no site's log saw * it before #8. * * @param int $user_id The user. * @param \WP_User $user The user object (core passes it since 5.5). */ public static function user_deleting( $user_id, $user = null ): void { self::$deleting_user = true; $login = $user instanceof \WP_User ? (string) $user->user_login : ''; if ( '' === $login ) { $found = get_userdata( (int) $user_id ); $login = $found ? (string) $found->user_login : (string) $user_id; } NetworkLog::log( 'user_deleted', sprintf( 'User %s deleted from the network', $login ), 'user', (int) $user_id, array( 'network_wide' => true ) ); } /** * The deletion is over (core fires `deleted_user` at the end of both * `wp_delete_user()` and `wpmu_delete_user()`). */ public static function user_deleted(): void { self::$deleting_user = false; } /** * @param mixed $tables The tables core will drop. * @param int $site_id The site being deleted. * @return mixed */ public static function drop_site_table( $tables, $site_id = 0 ) { global $wpdb; if ( ! is_array( $tables ) || (int) $site_id <= 0 ) { return $tables; } $tables[ Database::TABLE_NAME ] = $wpdb->get_blog_prefix( (int) $site_id ) . Database::TABLE_NAME; return $tables; } }