*/ public const FILTERS = array( 'user_id', 'user_email', 'event_type', 'object_type', 'object_id', 'date_from', 'date_to', 'search', 'country', 'device_class', 'marker' ); /** * The filters only the network log takes: `site` is a site id, or 0 for * the network's own rows. * * @var array */ public const NETWORK_FILTERS = array( 'site' ); /** * A row's fields as both surfaces present them, in order. * * @var array */ public const FIELDS = array( 'id', 'timestamp', 'user_id', 'user_email', 'ip_address', 'event_type', 'object_type', 'object_id', 'summary', 'details', 'country', 'city', 'device_class', 'browser', 'os', 'markers' ); /** * The fields only a network row has, appended after FIELDS. * * @var array */ public const NETWORK_FIELDS = array( 'site_id', 'site' ); /** * Turn flat inputs into ActivityLog::get_events() arguments. * * A filter that is absent, null or an empty string is not applied. * Anything else must be usable: a mistake in a script is refused rather * than quietly widened. ActivityLog::build_where() drops a date it cannot * parse, so without this a typo in date_from would return the whole log. * * @param array $input Values keyed by filter name; other keys are ignored. * @param bool $network Whether the caller reads the network log (and has checked `manage_network`). Only then is `site` read. * @return array Arguments for get_events() and count_events(), always carrying per_page and page. * @throws InvalidArgumentException Naming, in a translated sentence, the first input refused. */ public static function args( array $input, bool $network = false ): array { $args = array(); foreach ( array( 'user_id', 'object_id' ) as $key ) { if ( self::given( $input, $key ) ) { $args[ $key ] = self::whole_number( $key, $input[ $key ], 1, PHP_INT_MAX ); } } foreach ( array( 'user_email', 'event_type', 'object_type', 'search' ) as $key ) { if ( self::given( $input, $key ) ) { $args[ $key ] = self::text( $key, $input[ $key ] ); } } foreach ( array( 'date_from', 'date_to' ) as $key ) { if ( self::given( $input, $key ) ) { $args[ $key ] = self::date( $key, $input[ $key ] ); } } if ( self::given( $input, 'country' ) ) { $args['country'] = self::country( $input['country'] ); } if ( self::given( $input, 'device_class' ) ) { $args['device_class'] = self::one_of( 'device_class', $input['device_class'], UserAgent::CLASSES ); } if ( self::given( $input, 'marker' ) ) { $args['marker'] = self::one_of( 'marker', $input['marker'], LoginSignal::MARKERS ); } // The network log's site filter: read only in network mode, so a // site-scoped caller cannot name another site however it spells it. if ( $network && self::given( $input, 'site' ) ) { $args['blog_id'] = self::whole_number( 'site', $input['site'], 0, PHP_INT_MAX ); } $args['per_page'] = self::given( $input, 'per_page' ) ? self::whole_number( 'per_page', $input['per_page'], 1, self::MAX_PER_PAGE ) : self::DEFAULT_PER_PAGE; $args['page'] = self::given( $input, 'page' ) ? self::whole_number( 'page', $input['page'], 1, PHP_INT_MAX ) : 1; return $args; } /** * One page of matching events, and how many match in all. * * @param array $args From args(). * @param bool $network Read the network log (the caller has checked `manage_network`). * @return array{events: list>, total: int, pages: int} */ public static function fetch( array $args, bool $network = false ): array { if ( $network ) { $rows = NetworkLog::get_events( $args ); $labels = NetworkLog::site_labels( array_map( static fn( array $row ): int => (int) ( $row['blog_id'] ?? 0 ), $rows ) ); $events = array_map( static fn( array $row ): array => self::present( $row, $labels ), $rows ); } else { $events = array_map( static fn( array $row ): array => self::present( $row ), ActivityLog::get_events( $args ) ); } $total = self::total( $args, $network ); $per_page = max( 1, (int) ( $args['per_page'] ?? self::DEFAULT_PER_PAGE ) ); return array( 'events' => array_values( $events ), 'total' => $total, 'pages' => (int) ceil( $total / $per_page ), ); } /** * How many events match the filters, across every page. * * @param array $args From args(); the paging keys are ignored. * @param bool $network Count the network log (the caller has checked `manage_network`). */ public static function total( array $args, bool $network = false ): int { $filters = array_diff_key( $args, array( 'per_page' => true, 'page' => true, ) ); return $network ? NetworkLog::count_events( $filters ) : ActivityLog::count_events( $filters ); } /** * One stored row as both surfaces present it: numbers as numbers, absent * values as null, the details JSON decoded, the context columns as * stored and the login markers projected out of the details (an object, * empty for a row that has none). * * The address is the stored value, as it always was: truncated unless the * site keeps full addresses. The location was looked up from the full * address before truncation (docs/geo-device.md), and only its answer is * here -- nothing presented can recover the address. * * @param array $row One ActivityLog::get_events() or NetworkLog::get_events() row. * @param array|null $site_labels For a network row: NetworkLog::site_labels() of the page. Null for a site row, which has no site fields. * @return array */ public static function present( array $row, ?array $site_labels = null ): array { $details = isset( $row['details'] ) && is_string( $row['details'] ) && '' !== $row['details'] ? json_decode( $row['details'], true ) : null; $event = array( 'id' => (int) ( $row['id'] ?? 0 ), 'timestamp' => (string) ( $row['timestamp'] ?? '' ), 'user_id' => self::int_or_null( $row['user_id'] ?? null ), 'user_email' => self::text_or_null( $row['user_email'] ?? null ), 'ip_address' => self::text_or_null( $row['ip_address'] ?? null ), 'event_type' => (string) ( $row['event_type'] ?? '' ), 'object_type' => self::text_or_null( $row['object_type'] ?? null ), 'object_id' => self::int_or_null( $row['object_id'] ?? null ), 'summary' => (string) ( $row['summary'] ?? '' ), 'details' => is_array( $details ) ? $details : null, ); foreach ( array( 'country', 'city', 'device_class', 'browser', 'os' ) as $column ) { $event[ $column ] = self::text_or_null( $row[ $column ] ?? null ); } $event['markers'] = (object) LoginSignal::markers( $details ); if ( null !== $site_labels ) { $site_id = (int) ( $row['blog_id'] ?? 0 ); $event['site_id'] = $site_id; $event['site'] = $site_labels[ $site_id ] ?? (string) $site_id; } return $event; } /** * Whether a filter was supplied at all. Null and the empty string are how * a query string and a flag each say "not this one". * * @param array $input The inputs. * @param string $key The filter. */ private static function given( array $input, string $key ): bool { return isset( $input[ $key ] ) && '' !== $input[ $key ]; } /** * @param string $key The filter, for the message. * @param mixed $value What was given. * @param int $min Smallest acceptable value. * @param int $max Largest acceptable value; PHP_INT_MAX for "any". * @throws InvalidArgumentException When not a whole number in range. */ private static function whole_number( string $key, $value, int $min, int $max ): int { $text = is_int( $value ) ? (string) $value : ( is_string( $value ) ? trim( $value ) : '' ); if ( '' === $text || ! ctype_digit( $text ) ) { throw new InvalidArgumentException( sprintf( /* translators: 1: the filter's name, 2: what was given for it. */ __( '%1$s must be a whole number, not "%2$s".', '404ops-activity-log' ), $key, self::shown( $value ) ) ); } $number = (int) $text; if ( $number < $min || $number > $max ) { if ( PHP_INT_MAX === $max ) { throw new InvalidArgumentException( sprintf( /* translators: 1: the filter's name, 2: the smallest value it accepts, 3: what was given for it. */ __( '%1$s must be %2$d or more, not %3$d.', '404ops-activity-log' ), $key, $min, $number ) ); } throw new InvalidArgumentException( sprintf( /* translators: 1: the filter's name, 2: the smallest value it accepts, 3: the largest, 4: what was given for it. */ __( '%1$s must be between %2$d and %3$d, not %4$d.', '404ops-activity-log' ), $key, $min, $max, $number ) ); } return $number; } /** * @param string $key The filter, for the message. * @param mixed $value What was given. * @throws InvalidArgumentException When not text, or blank once cleaned. */ private static function text( string $key, $value ): string { if ( ! is_scalar( $value ) ) { throw new InvalidArgumentException( sprintf( /* translators: %s: the filter's name. */ __( '%s must be text.', '404ops-activity-log' ), $key ) ); } $clean = sanitize_text_field( (string) $value ); if ( '' === $clean ) { throw new InvalidArgumentException( sprintf( /* translators: %s: the filter's name. */ __( '%s must not be blank.', '404ops-activity-log' ), $key ) ); } return $clean; } /** * A date filter is kept as the text given, because build_where() reads it * itself; what is checked here is that build_where() will be able to. * * @param string $key 'date_from' or 'date_to'. * @param mixed $value What was given. * @throws InvalidArgumentException When the query would not understand it. */ private static function date( string $key, $value ): string { $text = is_scalar( $value ) ? trim( (string) $value ) : ''; if ( '' === $text || null === ActivityLog::date_bound( $key, $text ) ) { throw new InvalidArgumentException( sprintf( /* translators: 1: the filter's name, 2: what was given for it. */ __( '%1$s is not a date: "%2$s". Use YYYY-MM-DD, or a phrase such as "-7 days" or "yesterday".', '404ops-activity-log' ), $key, self::shown( $value ) ) ); } return $text; } /** * A country filter: an ISO 3166-1 alpha-2 code, either case, stored as * the log stores it (upper case). * * @param mixed $value What was given. * @throws InvalidArgumentException When it is not two letters. */ private static function country( $value ): string { $text = is_scalar( $value ) ? trim( (string) $value ) : ''; if ( 1 !== preg_match( '/^[A-Za-z]{2}$/', $text ) ) { throw new InvalidArgumentException( sprintf( /* translators: 1: the filter's name, 2: what was given for it. */ __( '%1$s must be a two-letter country code such as NZ, not "%2$s".', '404ops-activity-log' ), 'country', self::shown( $value ) ) ); } return strtoupper( $text ); } /** * A filter from a closed list. * * @param string $key The filter, for the message. * @param mixed $value What was given. * @param array $allowed The values it may take. * @throws InvalidArgumentException When it is not one of them. */ private static function one_of( string $key, $value, array $allowed ): string { $text = is_scalar( $value ) ? strtolower( trim( (string) $value ) ) : ''; if ( ! in_array( $text, $allowed, true ) ) { throw new InvalidArgumentException( sprintf( /* translators: 1: the filter's name, 2: the values it accepts, comma-separated, 3: what was given for it. */ __( '%1$s must be one of %2$s, not "%3$s".', '404ops-activity-log' ), $key, implode( ', ', $allowed ), self::shown( $value ) ) ); } return $text; } /** * @param mixed $value Anything, for a message. */ private static function shown( $value ): string { if ( is_bool( $value ) ) { return $value ? 'true' : 'false'; } return is_scalar( $value ) ? (string) $value : gettype( $value ); } /** * @param mixed $value A stored column value. */ private static function int_or_null( $value ): ?int { return null === $value || '' === $value ? null : (int) $value; } /** * @param mixed $value A stored column value. */ private static function text_or_null( $value ): ?string { return null === $value || '' === $value ? null : (string) $value; } }