the context key Simple History records that * header under (`_server_` + lowercased header + `_` + index), so an * imported row reads the same address a live row on this site would. */ private const IP_KEYS = array( 'remote_addr' => '_server_remote_addr', 'x_forwarded_for' => '_server_http_x_forwarded_for_0', 'x_real_ip' => '_server_http_x_real_ip_0', 'cf_connecting_ip' => '_server_http_cf_connecting_ip_0', ); /** * `logger:_message_key` => this plugin's event type. Anything not listed * is imported as a generic event. */ public const MESSAGE_MAP = array( 'SimpleUserLogger:user_logged_in' => 'user_login', 'SimpleUserLogger:user_logged_out' => 'user_logout', 'SimpleUserLogger:user_login_failed' => 'login_failed', 'SimpleUserLogger:user_unknown_login_failed' => 'login_failed', 'SimpleUserLogger:user_created' => 'user_created', 'SimpleUserLogger:user_deleted' => 'user_deleted', 'SimpleUserLogger:user_updated_profile' => 'user_profile_updated', 'SimpleUserLogger:user_role_updated' => 'user_role_changed', 'SimpleUserLogger:user_password_reseted' => 'user_password_reset', 'SimplePostLogger:post_created' => 'post_created', 'SimplePostLogger:post_updated' => 'post_updated', 'SimplePostLogger:post_trashed' => 'post_trashed', 'SimplePostLogger:post_restored' => 'post_restored', 'SimplePostLogger:post_deleted' => 'post_deleted', 'SimplePluginLogger:plugin_activated' => 'plugin_activated', 'SimplePluginLogger:plugin_deactivated' => 'plugin_deactivated', 'SimplePluginLogger:plugin_installed' => 'plugin_installed', 'SimplePluginLogger:plugin_updated' => 'plugin_updated', 'SimplePluginLogger:plugin_deleted' => 'plugin_deleted', 'SimpleThemeLogger:theme_switched' => 'theme_switched', 'SimpleThemeLogger:theme_installed' => 'theme_installed', 'SimpleThemeLogger:theme_updated' => 'theme_updated', 'SimpleThemeLogger:theme_deleted' => 'theme_deleted', 'SimpleCoreUpdatesLogger:core_updated' => 'core_updated', 'SimpleCoreUpdatesLogger:core_auto_updated' => 'core_updated', 'SimpleMediaLogger:attachment_created' => 'media_uploaded', 'SimpleMediaLogger:attachment_deleted' => 'media_deleted', 'SimpleOptionsLogger:option_updated' => 'setting_changed', 'SimpleMenuLogger:created_menu' => 'menu_created', 'SimpleMenuLogger:edited_menu' => 'menu_updated', 'SimpleMenuLogger:deleted_menu' => 'menu_deleted', ); public function key(): string { return self::KEY; } public function label(): string { return 'Simple History'; } private function events_table(): string { global $wpdb; return $wpdb->prefix . 'simple_history'; } private function contexts_table(): string { global $wpdb; return $wpdb->prefix . 'simple_history_contexts'; } public function detect(): Detection { $events = SourceTables::columns( $this->events_table() ); if ( null === $events ) { return Detection::absent(); } if ( ! in_array( 'logger', $events, true ) && in_array( 'action', $events, true ) ) { return Detection::unsupported( __( 'This is the Simple History 1.x table (events stored as action and object columns, with no contexts table). Update Simple History to version 2 or later once, so it upgrades its own table, then import.', '404ops-activity-log' ) ); } $missing = array_diff( self::EVENT_COLUMNS, $events ); if ( array() !== $missing ) { return Detection::unsupported( sprintf( /* translators: %s: comma-separated column names. */ __( 'The Simple History 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 ) ) ); } $contexts = SourceTables::columns( $this->contexts_table() ); if ( null === $contexts || array() !== array_diff( self::CONTEXT_COLUMNS, $contexts ) ) { return Detection::unsupported( __( 'The Simple History events table is here but its contexts table is missing or has an unknown shape, so the events\' users, addresses and values cannot be read. Nothing was read.', '404ops-activity-log' ) ); } $version = get_option( 'simple_history_db_version' ); return Detection::supported( is_scalar( $version ) && '' !== (string) $version /* translators: %s: Simple History's own database version number. */ ? sprintf( __( 'Simple History 2.0 or later (database version %s)', '404ops-activity-log' ), (string) $version ) : __( 'Simple History 2.0 or later', '404ops-activity-log' ) ); } public function preview(): array { global $wpdb; $table = $this->events_table(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- another plugin's table, name built from the prefix. $row = $wpdb->get_row( "SELECT COUNT(*) AS n, MIN(date) AS first, MAX(date) AS last, MAX(id) AS max_id FROM `{$table}`", 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; $events = $this->events_table(); $contexts = $this->contexts_table(); // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared -- another plugin's tables, names built from the prefix. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, date, logger, level, message, initiator FROM `{$events}` WHERE id > %d AND id <= %d 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' ) ); $context = array(); $found = $wpdb->get_results( $wpdb->prepare( "SELECT history_id, `key`, value FROM `{$contexts}` WHERE history_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', $ids ), ARRAY_A ); // phpcs:enable foreach ( is_array( $found ) ? $found : array() as $pair ) { $context[ (int) $pair['history_id'] ][ (string) $pair['key'] ] = (string) $pair['value']; } $mapped = array(); foreach ( $rows as $row ) { $mapped[] = self::map( $row, $context[ (int) $row['id'] ] ?? array(), EventLogger::ip_source() ); } return $mapped; } /** * One Simple History row as this plugin's event. Pure: no database, so * the mapping is unit-tested row by row. * * @param array $row The event row. * @param array $context Its context, key => value. * @param string $ip_source This site's IP source setting. * @return array */ public static function map( array $row, array $context, string $ip_source = 'remote_addr' ): array { $logger = (string) ( $row['logger'] ?? '' ); $message_key = (string) ( $context['_message_key'] ?? '' ); $type = self::MESSAGE_MAP[ $logger . ':' . $message_key ] ?? null; $withheld = array(); if ( 'SimpleOptionsLogger' === $logger && EventLogger::is_secret_setting( (string) ( $context['option'] ?? '' ) ) ) { $withheld = array( 'old_value', 'new_value' ); } $message = SourceTables::interpolate( (string) ( $row['message'] ?? '' ), $context, $withheld ); $ip_key = self::IP_KEYS[ $ip_source ] ?? self::IP_KEYS['remote_addr']; $ip = SourceTables::ip( $context[ $ip_key ] ?? null ) ?? SourceTables::ip( $context['_server_remote_addr'] ?? null ); $event = array( 'source_id' => (int) $row['id'], 'timestamp' => (string) $row['date'], 'user_id' => 'wp_user' === ( $row['initiator'] ?? '' ) ? SourceTables::id( $context['_user_id'] ?? null ) : null, 'user_email' => 'wp_user' === ( $row['initiator'] ?? '' ) ? SourceTables::email( $context['_user_email'] ?? null ) : null, 'ip' => $ip, 'event_type' => Importer::GENERIC_EVENT, 'object_type' => null, 'object_id' => null, 'summary' => SourceTables::summary( 'Imported (Simple History): ' . $message ), 'details' => array(), ); $imported = array( 'source' => self::KEY, 'id' => (int) $row['id'], 'type' => '' !== $message_key ? $logger . ':' . $message_key : $logger, 'level' => (string) ( $row['level'] ?? '' ), ); if ( null !== $type ) { $mapped = self::mapped( $type, $context, $withheld ); if ( null !== $mapped ) { $event = array_merge( $event, $mapped ); $event['event_type'] = $type; $imported['message'] = $message; } } $event['details']['imported'] = $imported; return $event; } /** * A mapped event's object, summary and details, in the same shape the * live logger writes for that type -- or null when the row lacks what * that shape needs, and it stays generic. * * @param string $type This plugin's event type. * @param array $c Context. * @param string[] $withheld Keys withheld for this row. * @return array|null */ private static function mapped( string $type, array $c, array $withheld ): ?array { $v = static fn( string $key ): string => SourceTables::value_text( $c[ $key ] ?? '' ); switch ( $type ) { case 'user_login': case 'user_logout': $login = $v( '_user_login' ) !== '' ? $v( '_user_login' ) : $v( 'user_login' ); if ( '' === $login ) { return null; } return self::shape( 'user', SourceTables::id( $c['_user_id'] ?? null ), sprintf( 'user_login' === $type ? 'User %s logged in' : 'User %s logged out', $login ) ); case 'login_failed': $login = $v( 'login' ) !== '' ? $v( 'login' ) : $v( 'failed_username' ); return self::shape( 'user', null, sprintf( 'Failed login attempt for %s', $login ), array( 'attempted_login' => $login ) ); case 'user_created': case 'user_deleted': $prefix = 'user_created' === $type ? 'created_user_' : 'deleted_user_'; if ( '' === $v( $prefix . 'login' ) ) { return null; } return self::shape( 'user', SourceTables::id( $c[ $prefix . 'id' ] ?? null ), sprintf( 'user_created' === $type ? 'User %s created' : 'User %s deleted', $v( $prefix . 'login' ) ) ); case 'user_profile_updated': if ( '' === $v( 'edited_user_login' ) ) { return null; } return self::shape( 'user', SourceTables::id( $c['edited_user_id'] ?? null ), sprintf( 'User %s profile updated', $v( 'edited_user_login' ) ) ); case 'user_role_changed': if ( '' === $v( 'edited_user_login' ) || '' === $v( 'new_role' ) ) { return null; } return self::shape( 'user', SourceTables::id( $c['edited_user_id'] ?? null ), sprintf( 'User %1$s role changed to %2$s', $v( 'edited_user_login' ), $v( 'new_role' ) ), array( 'old_roles' => '' !== $v( 'old_role' ) ? array( $v( 'old_role' ) ) : array(), 'new_role' => $v( 'new_role' ), ) ); case 'user_password_reset': if ( '' === $v( '_user_login' ) ) { return null; } return self::shape( 'user', SourceTables::id( $c['_user_id'] ?? null ), sprintf( 'User %s password reset', $v( '_user_login' ) ) ); case 'post_created': case 'post_updated': case 'post_trashed': case 'post_restored': case 'post_deleted': $post_type = sanitize_key( $v( 'post_type' ) ); 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', ); return self::shape( $post_type, SourceTables::id( $c['post_id'] ?? null ), sprintf( $verbs[ $type ], ucfirst( $post_type ), $v( 'post_title' ) ) ); case 'plugin_activated': case 'plugin_deactivated': case 'plugin_installed': case 'plugin_deleted': case 'plugin_updated': $name = $v( 'plugin_name' ); if ( '' === $name ) { return null; } $details = array( 'plugin' => '' !== $v( 'plugin_slug' ) ? $v( 'plugin_slug' ) : $name ); if ( 'plugin_updated' === $type ) { $details['from'] = $v( 'plugin_prev_version' ); $details['to'] = $v( 'plugin_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( 'plugin_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_switched': case 'theme_installed': case 'theme_updated': case 'theme_deleted': $name = $v( 'theme_name' ); if ( '' === $name ) { return null; } $details = array( 'theme' => '' !== $v( 'theme_slug' ) ? $v( 'theme_slug' ) : $name ); if ( 'theme_updated' === $type ) { $details['from'] = $v( 'theme_prev_version' ); $details['to'] = $v( 'theme_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_switched' => 'Theme switched to "%s"', 'theme_installed' => 'Theme "%s" installed', '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( 'prev_version' ) ? sprintf( 'WordPress updated from %1$s to %2$s', $v( 'prev_version' ), $v( 'new_version' ) ) : sprintf( 'WordPress updated to %s', $v( 'new_version' ) ), array( 'from' => $v( 'prev_version' ), 'to' => $v( 'new_version' ), ) ); case 'media_uploaded': case 'media_deleted': $name = '' !== $v( 'attachment_filename' ) ? $v( 'attachment_filename' ) : $v( 'attachment_title' ); if ( '' === $name ) { return null; } return self::shape( 'attachment', SourceTables::id( $c['attachment_id'] ?? null ), sprintf( 'media_uploaded' === $type ? 'Media "%s" uploaded' : 'Media "%s" deleted', $name ) ); case 'setting_changed': $option = (string) ( $c['option'] ?? '' ); if ( '' === $option ) { return null; } $label = EventLogger::WATCHED_SETTINGS[ $option ] ?? $option; return self::shape( 'setting', null, sprintf( 'Setting "%s" updated', SourceTables::value_text( $label ) ), array() !== $withheld ? array( 'option' => SourceTables::value_text( $option ), 'values_withheld' => true, ) : array( 'option' => SourceTables::value_text( $option ), 'old_value' => $v( 'old_value' ), 'new_value' => $v( 'new_value' ), ) ); case 'menu_created': case 'menu_updated': case 'menu_deleted': if ( '' === $v( 'menu_name' ) ) { return null; } $verbs = array( 'menu_created' => 'Menu "%s" created', 'menu_updated' => 'Menu "%s" updated', 'menu_deleted' => 'Menu "%s" deleted', ); return self::shape( 'nav_menu', SourceTables::id( $c['menu_id'] ?? null ), sprintf( $verbs[ $type ], $v( 'menu_name' ) ) ); } 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, ); } }