network = $network; } /** * Whether this source imports into the network log. */ public function network(): bool { return $this->network; } /** Columns the 3.0+ records table must have. */ private const RECORD_COLUMNS = array( 'ID', 'blog_id', 'object_id', 'user_id', 'summary', 'created', 'connector', 'context', 'action', 'ip' ); /** Columns the meta table must have. */ private const META_COLUMNS = array( 'meta_id', 'record_id', 'meta_key', 'meta_value' ); /** The actor's own record, by Stream version: 3.0+ and pre-3.0. */ private const ACTOR_META = array( 'user_meta', 'author_meta' ); /** A withheld value shorter than this is not searched for in text: it would eat into ordinary words. */ private const MIN_SCRUB_LENGTH = 3; /** * `connector:context:action` => this plugin's event type; `*` as the * context matches any (a post type, an attachment type, a menu or a * widget area -- Stream's context is the object's kind there). Anything * not listed is imported as a generic event. */ public const MAP = array( 'users:sessions:login' => 'user_login', 'users:sessions:logout' => 'user_logout', 'users:users:created' => 'user_created', 'users:users:deleted' => 'user_deleted', 'users:profiles:updated' => 'user_profile_updated', 'users:profiles:password-reset' => 'user_password_reset', 'posts:*:created' => 'post_created', 'posts:*:updated' => 'post_updated', 'posts:*:trashed' => 'post_trashed', 'posts:*:untrashed' => 'post_restored', 'posts:*:deleted' => 'post_deleted', 'installer:plugins:installed' => 'plugin_installed', 'installer:plugins:activated' => 'plugin_activated', 'installer:plugins:deactivated' => 'plugin_deactivated', 'installer:plugins:updated' => 'plugin_updated', 'installer:plugins:deleted' => 'plugin_deleted', 'installer:themes:installed' => 'theme_installed', 'installer:themes:activated' => 'theme_switched', 'installer:themes:updated' => 'theme_updated', 'installer:themes:deleted' => 'theme_deleted', 'installer:WordPress:updated' => 'core_updated', 'media:*:uploaded' => 'media_uploaded', 'media:*:attached' => 'media_uploaded', 'media:*:deleted' => 'media_deleted', 'settings:*:updated' => 'setting_changed', 'menus:*:created' => 'menu_created', 'menus:*:updated' => 'menu_updated', 'menus:*:deleted' => 'menu_deleted', 'widgets:*:added' => 'widgets_updated', 'widgets:*:removed' => 'widgets_updated', 'widgets:*:moved' => 'widgets_updated', 'widgets:*:updated' => 'widgets_updated', 'woocommerce:shop_order:updated' => 'wc_order_status_changed', ); /** Stream's widget action => the live row's change kind (AppearanceEvents). */ private const WIDGET_KINDS = array( 'added' => 'added', 'removed' => 'removed', 'moved' => 'moved', 'updated' => 'edited', ); public function key(): string { return $this->network ? self::NETWORK_KEY : self::KEY; } public function label(): string { return $this->network ? 'Stream (network admin)' : 'Stream'; } /** * Stream's tables are named from `$wpdb->base_prefix` (filterable by its * `wp_stream_db_tables_prefix`, which is not followed here -- a named * deferral in docs/history-import.md). */ private function records_table(): string { global $wpdb; return $wpdb->base_prefix . 'stream'; } private function meta_table(): string { global $wpdb; return $wpdb->base_prefix . 'stream_meta'; } public function detect(): Detection { $columns = SourceTables::columns( $this->records_table() ); if ( null === $columns ) { return Detection::absent(); } if ( ! in_array( 'connector', $columns, true ) && ( in_array( 'author', $columns, true ) || in_array( 'type', $columns, true ) ) ) { return Detection::unsupported( __( 'This is the Stream table from before version 3.0 (authors in the records table, connectors in a separate context table). Update Stream to 3.0 or later once, so it migrates its own table, then import.', '404ops-activity-log' ) ); } $missing = array_diff( self::RECORD_COLUMNS, $columns ); if ( array() !== $missing ) { return Detection::unsupported( sprintf( /* translators: %s: comma-separated column names. */ __( 'A Stream table is here but it 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->meta_table() ); if ( null === $meta || array() !== array_diff( self::META_COLUMNS, $meta ) ) { return Detection::unsupported( __( 'The Stream records table is here but its meta table is missing or has an unknown shape, so the records\' users and values cannot be read. Nothing was read.', '404ops-activity-log' ) ); } $version = get_site_option( 'wp_stream_db' ); return Detection::supported( is_scalar( $version ) && '' !== (string) $version /* translators: %s: Stream's own database version number. */ ? sprintf( __( 'Stream 3.0 or later (database version %s)', '404ops-activity-log' ), (string) $version ) : __( 'Stream 3.0 or later', '404ops-activity-log' ) ); } /** * Only this site's records, on a network and off it: Stream writes the * blog id on every row (1 on a single site), and 0 for what was done in * the network admin -- those rows belong to no site's log and are * imported by no site's source. Since #8 the network source reads * exactly them, into the network log (docs/network-log.md). */ private function blog_clause(): string { return ' AND blog_id = ' . ( $this->network ? 0 : (int) get_current_blog_id() ); } public function preview(): array { global $wpdb; $table = $this->records_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- another plugin's table; the blog clause is an integer. $row = $wpdb->get_row( "SELECT COUNT(*) AS n, MIN(created) AS first, MAX(created) AS last, MAX(ID) AS max_id FROM `{$table}` WHERE 1=1" . $this->blog_clause(), ARRAY_A ); return array( 'count' => (int) ( $row['n'] ?? 0 ), 'first' => isset( $row['first'] ) ? (string) $row['first'] : null, 'last' => isset( $row['last'] ) ? (string) $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->records_table(); $metas = $this->meta_table(); // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- another plugin's tables; the blog clause is an integer. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT ID, object_id, user_id, summary, created, connector, context, action, ip FROM `{$table}` WHERE ID > %d AND ID <= %d" . $this->blog_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 record_id, meta_key, meta_value FROM `{$metas}` WHERE record_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ') ORDER BY meta_id ASC', $ids ), ARRAY_A ); // phpcs:enable $meta = array(); foreach ( is_array( $found ) ? $found : array() as $pair ) { $meta[ (int) $pair['record_id'] ][ (string) $pair['meta_key'] ][] = SourceTables::decode( (string) $pair['meta_value'] ); } $mapped = array(); foreach ( $rows as $row ) { $mapped[] = self::map( $row, $meta[ (int) $row['ID'] ] ?? array(), self::accounts( $row ) ); } return $mapped; } /** * The accounts a record may name, as they are NOW: its actor, and for * the users connector the account it was about. map() uses one only * where the record itself proves it is the same person. * * @param array $row A records row. * @return array */ private static function accounts( array $row ): array { $ids = array( SourceTables::id( $row['user_id'] ?? null ) ); if ( 'users' === ( $row['connector'] ?? '' ) ) { $ids[] = SourceTables::id( $row['object_id'] ?? null ); } $accounts = array(); foreach ( array_filter( $ids ) as $id ) { $user = get_userdata( $id ); if ( $user instanceof \WP_User ) { $accounts[ $id ] = array( 'login' => (string) $user->user_login, 'display_name' => (string) $user->display_name, ); } } return $accounts; } /** * One Stream record as this plugin's event. Pure: the accounts it may * name are passed in, so the mapping is unit-tested row by row. * * @param array $row The records row. * @param array> $meta Its meta, key => decoded values in stored order. * @param array $accounts Accounts by id, as they are now (see accounts()). * @return array */ public static function map( array $row, array $meta, array $accounts = array() ): array { $connector = (string) ( $row['connector'] ?? '' ); $context = (string) ( $row['context'] ?? '' ); $action = (string) ( $row['action'] ?? '' ); $actor = self::actor( $meta ); $user_id = SourceTables::id( $row['user_id'] ?? null ); $withheld = self::withheld_values( $connector, $meta ); $message = SourceTables::summary( self::without_withheld( (string) ( $row['summary'] ?? '' ), $withheld ) ); $event = array( 'source_id' => (int) ( $row['ID'] ?? 0 ), 'timestamp' => (string) ( $row['created'] ?? '' ), 'user_id' => $user_id, 'user_email' => null !== $user_id ? $actor['email'] : null, 'ip' => SourceTables::ip( $row['ip'] ?? null ), 'event_type' => Importer::GENERIC_EVENT, 'object_type' => null, 'object_id' => null, 'summary' => SourceTables::summary( 'Imported (Stream): ' . $message ), 'details' => array(), ); $imported = array( 'source' => self::KEY, 'id' => (int) ( $row['ID'] ?? 0 ), 'type' => $connector . '/' . $context . '/' . $action, ); $type = self::MAP[ $connector . ':' . $context . ':' . $action ] ?? self::MAP[ $connector . ':*:' . $action ] ?? null; if ( null !== $type ) { $login = null !== $user_id ? self::proven_login( $user_id, $actor['values'], $accounts ) : ''; $mapped = self::mapped( $type, $row, $meta, $withheld, $login, $accounts ); if ( null !== $mapped ) { // A shape may name a narrower type than the map (a profile // update that carries a new role is a role change). $event = array_merge( $event, array( 'event_type' => $type ), $mapped ); $imported['message'] = $message; } } $event['details']['imported'] = $imported; return $event; } /** * The actor's own record: every value Stream kept about them, and the * email address among them. Keyed (3.0-3.1's serialised array, and * `author_meta`) or key-less (3.2+, one row per value, in the order * Stream wrote them: email first). These values name the actor for this * record and nothing else -- they are never shown. * * @param array> $meta Decoded meta. * @return array{email: ?string, values: string[]} */ private static function actor( array $meta ): array { $values = array(); $email = null; foreach ( self::ACTOR_META as $key ) { foreach ( $meta[ $key ] ?? array() as $value ) { if ( is_array( $value ) ) { $email = $email ?? SourceTables::email( $value['user_email'] ?? null ); $values[] = is_scalar( $value['user_login'] ?? null ) ? (string) $value['user_login'] : ''; $values[] = is_scalar( $value['display_name'] ?? null ) ? (string) $value['display_name'] : ''; continue; } if ( is_scalar( $value ) ) { $email = $email ?? SourceTables::email( (string) $value ); $values[] = (string) $value; } } } return array( 'email' => $email, 'values' => array_values( array_filter( $values, static fn( string $v ): bool => '' !== $v ) ), ); } /** * The actor's login, only when the record proves the account with that * id NOW is the person Stream recorded: its login is among the values * Stream kept about its actor. A reused id proves nothing. * * @param int $user_id Actor id. * @param string[] $recorded The actor's recorded values. * @param array $accounts Accounts now. */ private static function proven_login( int $user_id, array $recorded, array $accounts ): string { $login = (string) ( $accounts[ $user_id ]['login'] ?? '' ); if ( '' === $login ) { return ''; } foreach ( $recorded as $value ) { if ( 0 === strcasecmp( $value, $login ) ) { return $login; } } return ''; } /** * Values this record must not show anywhere: those under a withheld key * (SourceTables::is_withheld_key()), and a secret or email-shaped * setting's old and new value. The actor's own record is not listed: its * display name and login are what Stream's summaries legitimately say, * and the email address among its values is caught by shape in * without_withheld(). * * @param string $connector Connector. * @param array> $meta Decoded meta. * @return string[] */ private static function withheld_values( string $connector, array $meta ): array { $keys = array(); foreach ( array_keys( $meta ) as $key ) { if ( SourceTables::is_withheld_key( (string) $key ) ) { $keys[] = (string) $key; } } if ( 'settings' === $connector && self::setting_is_withheld( $meta ) ) { $keys[] = 'old_value'; $keys[] = 'value'; } $values = array(); foreach ( array_unique( $keys ) as $key ) { foreach ( $meta[ $key ] ?? array() as $value ) { $values[] = SourceTables::value_text( $value ); } } return array_values( array_filter( $values, static fn( string $v ): bool => '' !== $v ) ); } /** * Whether a settings record's values are ones the live logger never * stores: its option, or the field of an option group, is a secret by * EventLogger::is_secret_setting() or email-shaped. * * @param array> $meta Decoded meta. */ private static function setting_is_withheld( array $meta ): bool { foreach ( array( 'option', 'option_key' ) as $key ) { $name = self::first( $meta, $key ); if ( '' !== $name && ( EventLogger::is_secret_setting( $name ) || SourceTables::is_withheld_key( $name ) ) ) { return true; } } return false; } /** * Stream's rendered text without the values it must not show: each * withheld value (of at least MIN_SCRUB_LENGTH characters) and anything * shaped like an email address reads as SourceTables::WITHHELD. * * @param string $text Rendered text. * @param string[] $withheld Values to replace. */ public static function without_withheld( string $text, array $withheld ): string { usort( $withheld, static fn( string $a, string $b ): int => strlen( $b ) <=> strlen( $a ) ); foreach ( $withheld as $value ) { if ( strlen( $value ) >= self::MIN_SCRUB_LENGTH ) { $text = str_ireplace( $value, SourceTables::WITHHELD, $text ); } } $scrubbed = preg_replace( '/[A-Za-z0-9._%+\-]+@[A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)+/', SourceTables::WITHHELD, $text ); return is_string( $scrubbed ) ? $scrubbed : $text; } /** * A meta value as bounded text, the first stored under its key, or ''. * * @param array> $meta Decoded meta. * @param string $key Key. */ private static function first( array $meta, string $key ): string { $value = $meta[ $key ][0] ?? null; return null === $value ? '' : SourceTables::value_text( $value ); } /** * The account a users-connector record was about, as a login and id, * only when the account with that id now carries the name Stream * recorded for it (its display name then, or its login where it had * none). Otherwise the recorded name, and no id. * * @param mixed $object_id Recorded object id. * @param string $recorded Recorded display name. * @param array $accounts Accounts now. * @return array{0: string, 1: ?int} */ private static function subject( $object_id, string $recorded, array $accounts ): array { $id = SourceTables::id( $object_id ); $account = null !== $id ? ( $accounts[ $id ] ?? null ) : null; if ( null !== $account && '' !== $recorded && ( 0 === strcasecmp( $recorded, $account['display_name'] ) || 0 === strcasecmp( $recorded, $account['login'] ) ) ) { return array( $account['login'], $id ); } return array( $recorded, null ); } /** * A mapped record's object, summary and details, in the same shape the * live logger writes for that type -- or null when the record lacks what * that shape needs, and it stays generic. * * @param string $type This plugin's event type. * @param array $row The records row. * @param array> $meta Decoded meta. * @param string[] $withheld Values withheld for this record. * @param string $login The actor's proven login, or ''. * @param array $accounts Accounts now. * @return array|null */ private static function mapped( string $type, array $row, array $meta, array $withheld, string $login, array $accounts ): ?array { $v = static fn( string $key ): string => self::without_withheld( self::first( $meta, $key ), $withheld ); $context = (string) ( $row['context'] ?? '' ); $action = (string) ( $row['action'] ?? '' ); $object_id = SourceTables::id( $row['object_id'] ?? null ); switch ( $type ) { case 'user_login': case 'user_logout': $name = '' !== $login ? $login : $v( 'display_name' ); if ( '' === $name ) { return null; } return self::shape( 'user', '' !== $login ? SourceTables::id( $row['user_id'] ?? null ) : null, sprintf( 'user_login' === $type ? 'User %s logged in' : 'User %s logged out', $name ) ); case 'user_created': case 'user_deleted': case 'user_profile_updated': case 'user_password_reset': list( $name, $id ) = self::subject( $row['object_id'] ?? null, $v( 'display_name' ), $accounts ); if ( '' === $name ) { return null; } // Stream logs a role change as a profile update that // carries the new role (its label, as the site showed it). if ( 'user_profile_updated' === $type && '' !== $v( 'new_role' ) ) { return self::shape( 'user', $id, sprintf( 'User %1$s role changed to %2$s', $name, $v( 'new_role' ) ), array( 'old_roles' => '' !== $v( 'old_role' ) ? array( $v( 'old_role' ) ) : array(), 'new_role' => $v( 'new_role' ), ) ) + array( 'event_type' => 'user_role_changed' ); } $verbs = array( 'user_created' => 'User %s created', 'user_deleted' => 'User %s deleted', 'user_profile_updated' => 'User %s profile updated', 'user_password_reset' => 'User %s password reset', ); return self::shape( 'user', $id, sprintf( $verbs[ $type ], $name ) ); case 'post_created': case 'post_updated': case 'post_trashed': case 'post_restored': case 'post_deleted': $post_type = sanitize_key( $context ); 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 = array(); if ( 'post_updated' === $type && '' !== $v( 'new_status' ) && $v( 'old_status' ) !== $v( 'new_status' ) ) { $details['status'] = array( 'before' => $v( 'old_status' ), 'after' => $v( 'new_status' ), ); } return self::shape( $post_type, $object_id, sprintf( $verbs[ $type ], ucfirst( $post_type ), $v( 'post_title' ) ), $details ); case 'plugin_installed': case 'plugin_activated': case 'plugin_deactivated': case 'plugin_updated': case 'plugin_deleted': $name = $v( 'name' ); // A failed install or update is logged by Stream with its error; // the live logger writes only what succeeded. if ( '' === $name || '' !== $v( 'error' ) ) { return null; } $slug = '' !== $v( 'slug' ) ? $v( 'slug' ) : $v( 'plugin' ); $details = array( 'plugin' => '' !== $slug ? $slug : $name ); if ( 'plugin_updated' === $type ) { $details['from'] = $v( 'old_version' ); $details['to'] = $v( '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( '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_updated': case 'theme_deleted': $name = $v( 'name' ); if ( '' === $name || '' !== $v( 'error' ) ) { return null; } $details = array( 'theme' => '' !== $v( 'slug' ) ? $v( 'slug' ) : $name ); if ( 'theme_updated' === $type ) { $details['from'] = $v( 'old_version' ); $details['to'] = $v( 'version' ); $summary = '' !== $details['from'] ? sprintf( 'Theme "%1$s" updated from %2$s to %3$s', $name, $details['from'], $details['to'] ) : sprintf( 'Theme "%1$s" updated to %2$s', $name, $details['to'] ); } else { $verbs = array( 'theme_installed' => 'Theme "%s" installed', 'theme_switched' => 'Theme switched to "%s"', 'theme_deleted' => 'Theme "%s" deleted', ); $summary = sprintf( $verbs[ $type ], $name ); } return self::shape( 'theme', null, $summary, $details ); case 'core_updated': if ( '' === $v( 'new_version' ) ) { return null; } return self::shape( 'core', null, '' !== $v( 'old_version' ) ? sprintf( 'WordPress updated from %1$s to %2$s', $v( 'old_version' ), $v( 'new_version' ) ) : sprintf( 'WordPress updated to %s', $v( 'new_version' ) ), array( 'from' => $v( 'old_version' ), 'to' => $v( 'new_version' ), ) ); case 'media_uploaded': case 'media_deleted': if ( '' === $v( 'name' ) ) { return null; } return self::shape( 'attachment', $object_id, sprintf( 'media_uploaded' === $type ? 'Media "%s" uploaded' : 'Media "%s" deleted', $v( 'name' ) ) ); case 'setting_changed': $option = $v( 'option' ); if ( '' === $option ) { return null; } $field = $v( 'option_key' ); $label = EventLogger::WATCHED_SETTINGS[ $option ] ?? ( '' !== $v( 'label' ) ? $v( 'label' ) : $option ); $details = array( 'option' => $option ); if ( '' !== $field ) { $details['field'] = $field; } if ( self::setting_is_withheld( $meta ) ) { $details['values_withheld'] = true; } else { $details['old_value'] = $v( 'old_value' ); $details['new_value'] = $v( 'value' ); } return self::shape( 'setting', null, sprintf( 'Setting "%s" updated', $label ), $details ); case 'menu_created': case 'menu_updated': case 'menu_deleted': if ( '' === $v( 'name' ) ) { return null; } $verbs = array( 'menu_created' => 'Menu "%s" created', 'menu_updated' => 'Menu "%s" updated', 'menu_deleted' => 'Menu "%s" deleted', ); $menu = SourceTables::id( $meta['menu_id'][0] ?? null ) ?? $object_id; return self::shape( 'nav_menu', $menu, sprintf( $verbs[ $type ], $v( 'name' ) ) ); case 'widgets_updated': // One record is one widget; the live row coalesces a request's // changes, so an imported one is a change list of one, in the // live row's words (AppearanceEvents::flush_widgets()). $widget = $v( 'widget_id' ); $area = '' !== $v( 'sidebar_id' ) ? $v( 'sidebar_id' ) : $context; $kind = self::WIDGET_KINDS[ $action ] ?? ''; if ( '' === $widget || '' === $kind ) { return null; } $entries = array( 'added' => $widget . ' to ' . $area, 'removed' => $widget . ' from ' . $area, 'moved' => $widget . ' from ' . $v( 'old_sidebar_id' ) . ' to ' . $area, 'edited' => $widget, ); return self::shape( 'widget', null, sprintf( 'Widgets changed: 1 %s', $kind ), array( $kind => array( $entries[ $kind ] ) ) ); case 'wc_order_status_changed': // Only Stream's status-change record (it names both statuses); // its other order records are post saves, kept generic. $from = sanitize_key( (string) preg_replace( '/^wc-/', '', $v( 'old_status' ) ) ); $to = sanitize_key( (string) preg_replace( '/^wc-/', '', $v( 'new_status' ) ) ); if ( ! isset( $meta['old_status_name'] ) || null === $object_id || '' === $to || $from === $to ) { return null; } return self::shape( 'shop_order', $object_id, sprintf( 'Order #%1$d status changed from %2$s to %3$s', $object_id, '' !== $from ? $from : 'none', $to ), array( 'from' => $from, 'to' => $to, ) ); } 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, ); } }