*/ private const DEFAULT_FIELDS = array( 'id', 'timestamp', 'user_id', 'user_email', 'ip_address', 'event_type', 'object_type', 'object_id', 'summary' ); /** * Formats whose cells are flat text. The markers object is written as * "new_country, dormant_days=45" in these; json and yaml keep it an * object. * * @var array */ private const FLAT_FORMATS = array( 'table', 'csv' ); /** * Lists events, newest first. * * ## OPTIONS * * [--user_id=] * : Only events by this user account. * * [--user_email=] * : Only events logged under this email address. * * [--event_type=] * : Only this event type: user_login, user_logout, login_failed, user_created, user_deleted, user_role_changed, post_created, post_updated, post_trashed, post_restored, post_deleted, plugin_activated, plugin_deactivated, theme_switched, setting_changed, session_destroyed, sessions_destroyed, session_idle_expired or session_limit_evicted. * * [--object_type=] * : Only events about this kind of object, for example post, user or plugin. * * [--object_id=] * : Only events about the object with this ID. * * [--date_from=] * : Events from this moment on: a date (YYYY-MM-DD) or a phrase such as "-7 days". * * [--date_to=] * : Events up to the end of this day: a date (YYYY-MM-DD) or a phrase such as "yesterday". * * [--search=] * : Matches the summary, email address, IP address and event details. * * [--country=] * : Only events from this country: a two-letter ISO code such as NZ. * * [--device_class=] * : Only events made from this kind of device: desktop, mobile, tablet or bot. * * [--marker=] * : Only logins carrying this marker: new_country, new_device, impossible_travel, dormant_days or anonymous_network. * * [--network] * : Read the network log (every site's events and the network's own) instead of this site's. Multisite with the plugin network-activated only; run with --user, that user must be a super admin. * * [--site=] * : With --network: only events of this site, by its ID; 0 for the network's own events. * * [--per_page=] * : Events per page, 1 to 100. * --- * default: 20 * --- * * [--page=] * : Which page of events. * --- * default: 1 * --- * * [--fields=] * : Limit the output to specific fields. Add details for what the event recorded about itself, as JSON. * * [--format=] * : Render output in a particular format. * --- * default: table * options: * - table * - csv * - json * - yaml * - ids * --- * * ## AVAILABLE FIELDS * * * id * * timestamp * * user_id * * user_email * * ip_address * * event_type * * object_type * * object_id * * summary * * details * * country * * city * * device_class * * browser * * os * * markers * * site_id (--network only) * * site (--network only; shown by default) * * ## EXAMPLES * * # Plugin activations this week. * $ wp activity-log list --event_type=plugin_activated --date_from="-7 days" * * # Everything one account did, with the details, for another system. * $ wp activity-log list --user_id=7 --fields=id,timestamp,event_type,summary,details --format=json * * @subcommand list * * @param array $args Positional arguments; none are taken. * @param array $assoc_args Flags. */ public function list_( array $args, array $assoc_args ): void { $network = self::network_mode( $assoc_args ); $page = EventQuery::fetch( self::query_args( $assoc_args, $network ), $network ); $format = (string) \WP_CLI\Utils\get_flag_value( $assoc_args, 'format', 'table' ); // Read the format first: the Formatter takes its flags out of $assoc_args. $formatter = new Formatter( $assoc_args, $network ? array_merge( self::DEFAULT_FIELDS, array( 'site' ) ) : self::DEFAULT_FIELDS ); if ( 'ids' === $format ) { $formatter->display_items( array_column( $page['events'], 'id' ) ); return; } $formatter->display_items( self::rows( $page['events'], $format ) ); } /** * Events as `list` hands them to the formatter: as EventQuery presents * them, with the markers object written as text for a flat format. * * @param array> $events From EventQuery::fetch(). * @param string $format The --format. * @return array> */ public static function rows( array $events, string $format ): array { if ( ! in_array( $format, self::FLAT_FORMATS, true ) ) { return $events; } return array_map( static function ( array $event ): array { $parts = array(); foreach ( (array) ( $event['markers'] ?? array() ) as $marker => $value ) { $parts[] = true === $value ? (string) $marker : $marker . '=' . (string) $value; } $event['markers'] = implode( ', ', $parts ); return $event; }, $events ); } /** * Why `--network` is refused here, or null when it may run. * * The network log exists only on a network with the plugin * network-activated. A command run as a user (`--user`) is held to that * user's capability, `manage_network` -- the line the Network Admin * screen and the network REST route draw -- so a site administrator's * account cannot read it here either. With no user, the shell is the * boundary, as for every other subcommand. */ public static function network_refusal(): ?string { if ( ! NetworkLog::is_active() ) { return __( 'The network activity log exists only on a multisite network with the plugin network-activated.', '404ops-activity-log' ); } if ( get_current_user_id() > 0 && ! current_user_can( 'manage_network' ) ) { return __( 'Only a super admin may read the network activity log; the --user given is not one.', '404ops-activity-log' ); } return null; } /** * Whether this run reads the network log, ending the command when it may * not; `--site` without `--network` is refused rather than ignored, so a * script that meant another site is not answered with this one. * * @param array $assoc_args Flags. */ private static function network_mode( array $assoc_args ): bool { $network = (bool) \WP_CLI\Utils\get_flag_value( $assoc_args, 'network', false ); if ( ! $network ) { if ( isset( $assoc_args['site'] ) ) { WP_CLI::error( __( '--site reads the network log: add --network.', '404ops-activity-log' ) ); } return false; } $refusal = self::network_refusal(); if ( null !== $refusal ) { WP_CLI::error( $refusal ); } return true; } /** * Counts the events that match, across every page. * * ## OPTIONS * * [--user_id=] * : Only events by this user account. * * [--user_email=] * : Only events logged under this email address. * * [--event_type=] * : Only this event type, for example login_failed or plugin_activated. * * [--object_type=] * : Only events about this kind of object, for example post, user or plugin. * * [--object_id=] * : Only events about the object with this ID. * * [--date_from=] * : Events from this moment on: a date (YYYY-MM-DD) or a phrase such as "-7 days". * * [--date_to=] * : Events up to the end of this day: a date (YYYY-MM-DD) or a phrase such as "yesterday". * * [--search=] * : Matches the summary, email address, IP address and event details. * * [--country=] * : Only events from this country: a two-letter ISO code such as NZ. * * [--device_class=] * : Only events made from this kind of device: desktop, mobile, tablet or bot. * * [--marker=] * : Only logins carrying this marker: new_country, new_device, impossible_travel, dormant_days or anonymous_network. * * [--network] * : Count the network log instead of this site's. Multisite with the plugin network-activated only; run with --user, that user must be a super admin. * * [--site=] * : With --network: only events of this site, by its ID; 0 for the network's own events. * * ## EXAMPLES * * # Failed logins in the past hour. * $ wp activity-log count --event_type=login_failed --date_from="-1 hour" * 14 * * @param array $args Positional arguments; none are taken. * @param array $assoc_args Flags. */ public function count( array $args, array $assoc_args ): void { $network = self::network_mode( $assoc_args ); WP_CLI::line( (string) EventQuery::total( self::query_args( $assoc_args, $network ), $network ) ); } /** * Imports another activity log's history (#21): Simple History, WP * Activity Log or Stream, read from their own tables, which are never * changed. * * The same run as the Import tab's, batch by batch, so it carries on a run * the screen started, and a second run only adds what is new. The same retention, exclusion, IP and * withholding rules apply. * * ## OPTIONS * * * : Which history to import. * --- * options: * - simple_history * - wsal * - stream * --- * * [--preview] * : Show what was found, and import nothing. * * ## EXAMPLES * * # See what Simple History holds. * $ wp activity-log import simple_history --preview * * # Import it. * $ wp activity-log import simple_history * * @param array $args The source. * @param array $assoc_args Flags. */ public function import( array $args, array $assoc_args ): void { $source = Importer::source( (string) ( $args[0] ?? '' ) ); if ( null === $source ) { WP_CLI::error( 'Unknown source. Use simple_history, wsal or stream.' ); return; } $detection = $source->detect(); if ( ! $detection->is_supported() ) { WP_CLI::error( '' !== $detection->reason ? $detection->reason : sprintf( 'No %s tables were found on this site.', $source->label() ) ); return; } $preview = $source->preview(); WP_CLI::line( sprintf( '%s: %d events, %s to %s (UTC). %s.', $source->label(), $preview['count'], $preview['first'] ?? '-', $preview['last'] ?? '-', $detection->shape ) ); if ( \WP_CLI\Utils\get_flag_value( $assoc_args, 'preview', false ) ) { return; } $started = Importer::start( $source, get_current_user_id() ); if ( 'empty' === $started ) { WP_CLI::success( 'The source has no events.' ); return; } $before = -1; do { $state = Importer::run( $source->key(), 30.0 ); if ( (int) $state['processed'] === $before ) { // Another driver (WP-Cron, an open Import tab) holds the run's // lock and is making the progress; wait for it. sleep( 1 ); continue; } $before = (int) $state['processed']; WP_CLI::line( sprintf( '%d of %d processed', min( (int) $state['total'], $before ), (int) $state['total'] ) ); } while ( Importer::RUNNING === $state['status'] ); WP_CLI::success( sprintf( '%d imported (%d as generic events); skipped: %s.', (int) $state['imported'], (int) $state['generic'], wp_json_encode( (object) $state['skipped'] ) ) ); } /** * The flags as query arguments, or the command ends with the mistake * named and exit status 1. * * @param array $assoc_args Flags. * @param bool $network Whether network_mode() allowed the network log. * @return array */ private static function query_args( array $assoc_args, bool $network = false ): array { try { return EventQuery::args( $assoc_args, $network ); } catch ( InvalidArgumentException $e ) { WP_CLI::error( $e->getMessage() ); } return array(); // Not reached: error() ends the command. } }