Name%`. * * Supported: the 4.4+ shape, where the user and address are columns. * Refused, by name: the 4.3-and-older shape (`is_read` / `is_migrated`, * user and address in metadata) -- WP Activity Log's own 4.4 migration * moves those into columns. A row that migration had not reached yet (it * runs in the background) still has an empty user and address column; for * those the metadata's `ClientIP` / `CurrentUserID` / `Username` are read * instead, case-insensitively as the migration itself matched them. * * WP Activity Log's message texts live in its code, not its tables, so the * mapped alerts (ALERT_MAP) are rendered in this plugin's own words, and * every other alert is imported as a generic `imported_event` naming its * alert id, object and event type, with a bounded list of its recorded * values -- none under a withheld key. */ final class Wsal implements Source { public const KEY = 'wsal'; /** Columns the 4.4+ occurrences table must have. */ private const OCCURRENCE_COLUMNS = array( 'id', 'site_id', 'alert_id', 'created_on', 'client_ip', 'object', 'event_type', 'username', 'user_id' ); /** Columns the metadata table must have. */ private const METADATA_COLUMNS = array( 'occurrence_id', 'name', 'value' ); /** Values a generic row lists, at most. */ private const GENERIC_VALUES = 8; /** * Alert id => this plugin's event type, for the alerts with an * equivalent here. Ids from the plugin's `defaults.php`. */ public const ALERT_MAP = array( 1000 => 'user_login', 1001 => 'user_logout', 1002 => 'login_failed', 1003 => 'login_failed', 4000 => 'user_created', 4001 => 'user_created', 4002 => 'user_role_changed', 4003 => 'user_password_changed', 4004 => 'user_password_changed', 4005 => 'user_email_changed', 4006 => 'user_email_changed', 4007 => 'user_deleted', 2000 => 'post_created', 2001 => 'post_updated', 2002 => 'post_updated', 2065 => 'post_updated', 2012 => 'post_trashed', 2014 => 'post_restored', 2008 => 'post_deleted', 5000 => 'plugin_installed', 5001 => 'plugin_activated', 5002 => 'plugin_deactivated', 5003 => 'plugin_deleted', 5004 => 'plugin_updated', 5005 => 'theme_installed', 5006 => 'theme_switched', 5007 => 'theme_deleted', 5031 => 'theme_updated', 6004 => 'core_updated', 2010 => 'media_uploaded', 2011 => 'media_deleted', 2078 => 'menu_created', 2081 => 'menu_deleted', 6002 => 'setting_changed', 6003 => 'setting_changed', 6005 => 'setting_changed', 6024 => 'setting_changed', 6025 => 'setting_changed', 6059 => 'setting_changed', ); /** * Settings alerts => [option, old-value metadata, new-value metadata]. * 6003 is the admin email: imported with its values withheld, as every * email address in an imported row is (SourceTables::is_withheld_key()). */ private const SETTING_ALERTS = array( 6002 => array( 'default_role', 'OldRole', 'NewRole' ), 6003 => array( 'admin_email', 'OldEmail', 'NewEmail' ), 6005 => array( 'permalink_structure', 'OldPattern', 'NewPattern' ), 6024 => array( 'siteurl', 'old_url', 'new_url' ), 6025 => array( 'home', 'old_url', 'new_url' ), 6059 => array( 'blogname', 'previous_value', 'new_value' ), ); /** `username` values that name no account. */ private const NOT_A_USER = array( 'system', 'unknown user', 'deleted', 'plugins', 'woocommerce system', '' ); public function key(): string { return self::KEY; } public function label(): string { return 'WP Activity Log'; } private function occurrences_table(): string { global $wpdb; return $wpdb->base_prefix . 'wsal_occurrences'; } private function metadata_table(): string { global $wpdb; return $wpdb->base_prefix . 'wsal_metadata'; } public function detect(): Detection { $columns = SourceTables::columns( $this->occurrences_table() ); if ( null === $columns ) { return Detection::absent(); } if ( in_array( 'is_migrated', $columns, true ) || ( in_array( 'is_read', $columns, true ) && ! in_array( 'client_ip', $columns, true ) ) ) { return Detection::unsupported( __( 'This is the WP Activity Log table from version 4.3 or older, which keeps users and addresses in its metadata. Update WP Activity Log to 4.4 or later and let its data migration finish, then import.', '404ops-activity-log' ) ); } $missing = array_diff( self::OCCURRENCE_COLUMNS, $columns ); if ( array() !== $missing ) { return Detection::unsupported( sprintf( /* translators: %s: comma-separated column names. */ __( 'The WP Activity Log table is missing columns this importer needs (%s), so it is not a version this importer knows. Nothing was read.', '404ops-activity-log' ), implode( ', ', $missing ) ) ); } $meta = SourceTables::columns( $this->metadata_table() ); if ( null === $meta || array() !== array_diff( self::METADATA_COLUMNS, $meta ) ) { return Detection::unsupported( __( 'The WP Activity Log events table is here but its metadata table is missing or has an unknown shape, so the events\' values cannot be read. Nothing was read.', '404ops-activity-log' ) ); } return Detection::supported( __( 'WP Activity Log 4.4 or later', '404ops-activity-log' ) ); } /** * On a network the table holds every site's rows; only this site's are * imported, into this site's log. */ private function site_clause(): string { return is_multisite() ? ' AND site_id = ' . (int) get_current_blog_id() : ''; } public function preview(): array { global $wpdb; $table = $this->occurrences_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- another plugin's table; the site clause is an integer. $row = $wpdb->get_row( "SELECT COUNT(*) AS n, MIN(created_on) AS first, MAX(created_on) AS last, MAX(id) AS max_id FROM `{$table}` WHERE 1=1" . $this->site_clause(), ARRAY_A ); return array( 'count' => (int) ( $row['n'] ?? 0 ), 'first' => self::timestamp( $row['first'] ?? null ), 'last' => self::timestamp( $row['last'] ?? null ), 'max_id' => (int) ( $row['max_id'] ?? 0 ), ); } public function rows( int $after_id, int $max_id, int $limit ): array { global $wpdb; $table = $this->occurrences_table(); $metas = $this->metadata_table(); // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- another plugin's tables; the site clause is an integer. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, alert_id, created_on, client_ip, object, event_type, username, user_id FROM `{$table}` WHERE id > %d AND id <= %d" . $this->site_clause() . ' ORDER BY id ASC LIMIT %d', $after_id, $max_id, $limit ), ARRAY_A ); if ( ! is_array( $rows ) || array() === $rows ) { return array(); } $ids = array_map( 'intval', array_column( $rows, 'id' ) ); $found = $wpdb->get_results( $wpdb->prepare( "SELECT occurrence_id, name, value FROM `{$metas}` WHERE occurrence_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', $ids ), ARRAY_A ); // phpcs:enable $meta = array(); foreach ( is_array( $found ) ? $found : array() as $pair ) { $meta[ (int) $pair['occurrence_id'] ][ (string) $pair['name'] ] = self::decode( (string) $pair['value'] ); } $mapped = array(); foreach ( $rows as $row ) { $event = self::map( $row, $meta[ (int) $row['id'] ] ?? array() ); // WP Activity Log records a login, never an address: take the // account's address now, only while that id still names that // login, so a reused id cannot pin someone else's address on it. if ( null !== $event['user_id'] ) { $user = get_userdata( (int) $event['user_id'] ); $event['user_email'] = $user instanceof \WP_User && strtolower( (string) $user->user_login ) === strtolower( (string) $event['login'] ) ? SourceTables::email( $user->user_email ) : null; } unset( $event['login'] ); // The account an event was about is recorded by login only; while // that login still exists it becomes the row's `user` object, so // the personal-data tools find the row by it (Privacy\PersonalData). $subject = (string) ( $event['subject_login'] ?? '' ); if ( 'user' === $event['object_type'] && null === $event['object_id'] && '' !== $subject ) { $user = get_user_by( 'login', $subject ); $event['object_id'] = $user instanceof \WP_User ? (int) $user->ID : null; } unset( $event['subject_login'] ); $mapped[] = $event; } return $mapped; } /** * A metadata value as WP Activity Log stored it: plain, or * `maybe_serialize()`d. Serialised values are read by SerializedValue, * never unserialize(): no class of any plugin is ever instantiated from * another plugin's table, and a stdClass reads as an array, so a `->` * path walks one shape. A serialised value it will not read (another * class, a reference, a malformed string) is dropped, not shown raw. * The reading itself is SourceTables::decode(), shared with Stream. * * @param string $value Stored value. * @return mixed */ public static function decode( string $value ) { return SourceTables::decode( $value ); } /** * A `created_on` Unix time as a UTC datetime, or null. * * @param mixed $value Stored value. */ public static function timestamp( $value ): ?string { if ( ! is_numeric( $value ) || (float) $value <= 0 ) { return null; } return gmdate( 'Y-m-d H:i:s', (int) floor( (float) $value ) ); } /** * A metadata value by WP Activity Log's own path syntax (`PluginData->Name`), * as bounded text, or '' when absent. * * @param array $meta Decoded metadata. * @param string $path Name, optionally with `->` parts. */ private static function meta( array $meta, string $path ): string { $parts = explode( '->', $path ); $value = $meta[ array_shift( $parts ) ] ?? null; foreach ( $parts as $part ) { $value = is_array( $value ) && array_key_exists( $part, $value ) ? $value[ $part ] : null; } return null === $value ? '' : SourceTables::value_text( $value ); } /** * A metadata value by name, matched case-insensitively (the 4.4 * migration's own rule for the legacy names). * * @param array $meta Decoded metadata. * @param string $name Name. * @return mixed */ private static function meta_ci( array $meta, string $name ) { foreach ( $meta as $key => $value ) { if ( 0 === strcasecmp( (string) $key, $name ) ) { return $value; } } return null; } /** * One occurrence as this plugin's event. Pure: rows() fills in what needs * the site's accounts -- the actor's address (checked against the extra * `login` key) and the affected account (from `subject_login`) -- and * drops both keys. * * @param array $row The occurrence row. * @param array $meta Its decoded metadata. * @return array */ public static function map( array $row, array $meta ): array { $alert = (int) ( $row['alert_id'] ?? 0 ); $user_id = SourceTables::id( $row['user_id'] ?? null ) ?? SourceTables::id( self::meta_ci( $meta, 'CurrentUserID' ) ); $username = trim( (string) ( $row['username'] ?? '' ) ); if ( '' === $username && is_string( self::meta_ci( $meta, 'Username' ) ) ) { $username = trim( (string) self::meta_ci( $meta, 'Username' ) ); } $is_user = null !== $user_id && ! in_array( strtolower( $username ), self::NOT_A_USER, true ); $ip = SourceTables::ip( $row['client_ip'] ?? null ) ?? SourceTables::ip( self::meta_ci( $meta, 'ClientIP' ) ); $object = SourceTables::value_text( $row['object'] ?? '' ); $event_type = SourceTables::value_text( $row['event_type'] ?? '' ); $event = array( 'source_id' => (int) $row['id'], 'timestamp' => (string) self::timestamp( $row['created_on'] ?? null ), 'user_id' => $is_user ? $user_id : null, 'user_email' => null, 'login' => $is_user ? $username : '', 'ip' => $ip, 'event_type' => Importer::GENERIC_EVENT, 'object_type' => null, 'object_id' => null, 'summary' => SourceTables::summary( 'Imported (WP Activity Log): ' . self::generic_text( $alert, $object, $event_type, $meta ) ), 'details' => array(), ); $type = self::ALERT_MAP[ $alert ] ?? null; $mapped = null !== $type ? self::mapped( $alert, $type, $meta, $is_user ? $username : '', $is_user ? $user_id : null ) : null; if ( null !== $mapped ) { $event = array_merge( $event, $mapped ); $event['event_type'] = (string) $type; } $event['details']['imported'] = array( 'source' => self::KEY, 'id' => (int) $row['id'], 'type' => 'alert ' . $alert, 'object' => $object, 'action' => $event_type, ); return $event; } /** * A generic row's text: which alert, about what, and a bounded list of * its recorded values, none under a withheld key. * * @param int $alert Alert id. * @param string $about Its object. * @param string $event_type Its event type. * @param array $meta Decoded metadata. */ private static function generic_text( int $alert, string $about, string $event_type, array $meta ): string { $text = sprintf( 'event %d (%s, %s)', $alert, '' !== $about ? $about : '?', '' !== $event_type ? $event_type : '?' ); $values = array(); foreach ( $meta as $name => $value ) { $name = (string) $name; if ( count( $values ) >= self::GENERIC_VALUES ) { break; } if ( SourceTables::is_withheld_key( $name ) || in_array( strtolower( $name ), array( 'clientip', 'currentuserid', 'username', 'currentuserroles', 'useragent', 'sessionid' ), true ) ) { continue; } $shown = SourceTables::value_text( $value ); if ( '' !== $shown ) { $values[] = $name . '=' . $shown; } } return array() !== $values ? $text . ': ' . implode( '; ', $values ) : $text; } /** * A mapped alert's object, summary and details, or null to keep it * generic when the metadata lacks what its shape needs. * * @param int $alert Alert id. * @param string $type This plugin's event type. * @param array $m Decoded metadata. * @param string $login The actor's login, or ''. * @param int|null $user_id The actor, or null. * @return array|null */ private static function mapped( int $alert, string $type, array $m, string $login, ?int $user_id ): ?array { $v = static fn( string $path ): string => self::meta( $m, $path ); switch ( $type ) { case 'user_login': case 'user_logout': if ( '' === $login ) { return null; } return self::shape( 'user', $user_id, sprintf( 'user_login' === $type ? 'User %s logged in' : 'User %s logged out', $login ) ); case 'login_failed': $attempted = 1003 === $alert ? $v( 'Users' ) : ( '' !== $v( 'Users' ) ? $v( 'Users' ) : $login ); return self::shape( 'user', null, sprintf( 'Failed login attempt for %s', $attempted ), array( 'attempted_login' => $attempted ) ); case 'user_created': if ( '' === $v( 'NewUserData->Username' ) ) { return null; } return self::shape( 'user', null, sprintf( 'User %s created', $v( 'NewUserData->Username' ) ) ) + array( 'subject_login' => $v( 'NewUserData->Username' ) ); case 'user_deleted': if ( '' === $v( 'TargetUserData->Username' ) ) { return null; } return self::shape( 'user', null, sprintf( 'User %s deleted', $v( 'TargetUserData->Username' ) ) ) + array( 'subject_login' => $v( 'TargetUserData->Username' ) ); case 'user_role_changed': if ( '' === $v( 'TargetUsername' ) || '' === $v( 'NewRole' ) ) { return null; } return self::shape( 'user', null, sprintf( 'User %1$s role changed to %2$s', $v( 'TargetUsername' ), $v( 'NewRole' ) ), array( 'old_roles' => '' !== $v( 'OldRole' ) ? array( $v( 'OldRole' ) ) : array(), 'new_role' => $v( 'NewRole' ), ) ) + array( 'subject_login' => $v( 'TargetUsername' ) ); case 'user_password_changed': case 'user_email_changed': // Own change (4003, 4005): the actor's account. Another's // (4004, 4006): named in metadata. Never an address. $own = in_array( $alert, array( 4003, 4005 ), true ); $subject = $own ? $login : ( 4004 === $alert ? $v( 'TargetUserData->Username' ) : $v( 'TargetUsername' ) ); if ( '' === $subject ) { return null; } return self::shape( 'user', $own ? $user_id : null, sprintf( 'user_password_changed' === $type ? 'User %s password changed' : 'User %s email address changed', $subject ), array( 'fields' => array( 'user_password_changed' === $type ? 'password' : 'email' ) ) ) + array( 'subject_login' => $own ? '' : $subject ); case 'post_created': case 'post_updated': case 'post_trashed': case 'post_restored': case 'post_deleted': $post_type = sanitize_key( $v( 'PostType' ) ); if ( '' === $post_type || EventLogger::is_internal_post_type( $post_type ) ) { return null; } $verbs = array( 'post_created' => '%s "%s" created', 'post_updated' => '%s "%s" updated', 'post_trashed' => '%s "%s" moved to trash', 'post_restored' => '%s "%s" restored from trash', 'post_deleted' => '%s "%s" deleted', ); $details = 2001 === $alert ? array( 'status' => array( 'after' => 'publish' ) ) : array(); return self::shape( $post_type, SourceTables::id( $m['PostID'] ?? null ), sprintf( $verbs[ $type ], ucfirst( $post_type ), $v( 'PostTitle' ) ), $details ); case 'plugin_installed': case 'plugin_activated': case 'plugin_deactivated': case 'plugin_deleted': case 'plugin_updated': $name = $v( 'PluginData->Name' ); if ( '' === $name ) { return null; } $details = array( 'plugin' => '' !== $v( 'PluginFile' ) ? $v( 'PluginFile' ) : $name ); if ( 'plugin_updated' === $type ) { $details['from'] = $v( 'OldVersion' ); $details['to'] = $v( 'PluginData->Version' ); $summary = '' !== $details['from'] ? sprintf( 'Plugin "%1$s" updated from %2$s to %3$s', $name, $details['from'], $details['to'] ) : sprintf( 'Plugin "%1$s" updated to %2$s', $name, $details['to'] ); } elseif ( 'plugin_installed' === $type ) { $details['version'] = $v( 'PluginData->Version' ); $summary = trim( sprintf( 'Plugin "%1$s" %2$s', $name, $details['version'] ) ) . ' installed'; } else { $verbs = array( 'plugin_activated' => 'Plugin "%s" activated', 'plugin_deactivated' => 'Plugin "%s" deactivated', 'plugin_deleted' => 'Plugin "%s" deleted', ); $summary = sprintf( $verbs[ $type ], $name ); } return self::shape( 'plugin', null, $summary, $details ); case 'theme_installed': case 'theme_switched': case 'theme_deleted': case 'theme_updated': $name = $v( 'Theme->Name' ); if ( '' === $name ) { return null; } $verbs = array( 'theme_installed' => 'Theme "%s" installed', 'theme_switched' => 'Theme switched to "%s"', 'theme_deleted' => 'Theme "%s" deleted', 'theme_updated' => 'Theme "%s" updated', ); $details = array( 'theme' => $name ); if ( 'theme_updated' === $type ) { $details['to'] = $v( 'Theme->Version' ); } return self::shape( 'theme', null, sprintf( $verbs[ $type ], $name ), $details ); case 'core_updated': if ( '' === $v( 'NewVersion' ) ) { return null; } return self::shape( 'core', null, '' !== $v( 'OldVersion' ) ? sprintf( 'WordPress updated from %1$s to %2$s', $v( 'OldVersion' ), $v( 'NewVersion' ) ) : sprintf( 'WordPress updated to %s', $v( 'NewVersion' ) ), array( 'from' => $v( 'OldVersion' ), 'to' => $v( 'NewVersion' ), ) ); case 'media_uploaded': case 'media_deleted': if ( '' === $v( 'FileName' ) ) { return null; } return self::shape( 'attachment', null, sprintf( 'media_uploaded' === $type ? 'Media "%s" uploaded' : 'Media "%s" deleted', $v( 'FileName' ) ) ); case 'menu_created': case 'menu_deleted': if ( '' === $v( 'MenuName' ) ) { return null; } return self::shape( 'nav_menu', null, sprintf( 'menu_created' === $type ? 'Menu "%s" created' : 'Menu "%s" deleted', $v( 'MenuName' ) ) ); case 'setting_changed': list( $option, $old_key, $new_key ) = self::SETTING_ALERTS[ $alert ]; $withheld = EventLogger::is_secret_setting( $option ) || SourceTables::is_withheld_key( $old_key ) || SourceTables::is_withheld_key( $new_key ); return self::shape( 'setting', null, sprintf( 'Setting "%s" updated', EventLogger::WATCHED_SETTINGS[ $option ] ), $withheld ? array( 'option' => $option, 'values_withheld' => true, ) : array( 'option' => $option, 'old_value' => $v( $old_key ), 'new_value' => $v( $new_key ), ) ); } return null; } /** * @param string $object_type Object type. * @param int|null $object_id Object id. * @param string $summary Summary. * @param array $details Details. * @return array */ private static function shape( string $object_type, ?int $object_id, string $summary, array $details = array() ): array { return array( 'object_type' => $object_type, 'object_id' => $object_id, 'summary' => SourceTables::summary( $summary ), 'details' => $details, ); } }