*/ public const MARKERS = array( 'new_country', 'new_device', 'impossible_travel', 'dormant_days', 'anonymous_network' ); /** * The markers a row's details carry, and nothing else from them. * * A PROJECTION, not a filter over the details: only the keys in MARKERS, * and only values of the shape LoginSignal writes (true; a day count for * dormancy), so a details payload of any other shape -- an imported row, * a hand-edited one, another event type that happens to use one of the * names -- yields nothing it did not mean. This is what lets a row's * markers leave through REST, WP-CLI and forwarding while the details * themselves (free-form, unbounded) do not have to. * * @param mixed $details The decoded details array, the stored JSON string, or null. * @return array Marker => true, or `dormant_days` => days; only those present. */ public static function markers( $details ): array { if ( is_string( $details ) && '' !== $details ) { $details = json_decode( $details, true ); } if ( ! is_array( $details ) ) { return array(); } $markers = array(); foreach ( self::MARKERS as $key ) { $value = $details[ $key ] ?? null; if ( 'dormant_days' === $key ) { if ( is_int( $value ) && $value >= self::DORMANT_MARKER_DAYS && $value <= 36500 ) { $markers[ $key ] = $value; } } elseif ( true === $value ) { $markers[ $key ] = true; } } return $markers; } /** * The literal JSON fragment a stored login row carries for one marker, * for a `details LIKE` predicate -- `"new_country":true`, or * `"dormant_days":` for the one marker that is a number. Null for a name * that is not a marker. wp_json_encode() writes these unspaced, and a * quote inside any other JSON string value is escaped (`\"`), so the * fragment can only match a top-level key of that name. * * @param string $marker One of MARKERS. */ public static function stored_fragment( string $marker ): ?string { if ( ! in_array( $marker, self::MARKERS, true ) ) { return null; } return 'dormant_days' === $marker ? '"dormant_days":' : '"' . $marker . '":true'; } /** * The flags for a login about to be written, as details entries. * * @param int $user_id The user logging in. * @param array{country: string|null, city: string|null, device_class: string|null, browser: string|null, os: string|null} $context This login's context. * @param bool $anonymous Whether this login's address is an anonymising network. * @param int|null $now The login's time (Unix), for tests; null for now. * @return array Only the markers that apply. */ public static function flags( int $user_id, array $context, bool $anonymous = false, ?int $now = null ): array { if ( $user_id <= 0 ) { return array(); } $now = $now ?? time(); $device = self::device_key( $context ); $earlier = self::earlier_logins( $user_id ); $flags = array(); if ( $anonymous ) { $flags['anonymous_network'] = true; } $countries = array_values( array_filter( array_map( static fn( array $row ) => $row['country'] ?? null, $earlier ) ) ); if ( null !== $context['country'] && array() !== $countries && ! in_array( $context['country'], $countries, true ) ) { $flags['new_country'] = true; } $devices = array_values( array_filter( array_map( array( __CLASS__, 'device_key' ), $earlier ) ) ); if ( null !== $device && array() !== $devices && ! in_array( $device, $devices, true ) ) { $flags['new_device'] = true; } if ( ! $anonymous && null !== $context['country'] ) { $anchor = self::last_known_location( $earlier ); if ( null !== $anchor && Travel::is_impossible( $anchor['country'], $context['country'], $now - $anchor['time'] ) ) { $flags['impossible_travel'] = true; } } $previous = array() !== $earlier ? self::utc_time( $earlier[0]['timestamp'] ?? null ) : null; if ( null !== $previous ) { $days = intdiv( max( 0, $now - $previous ), DAY_IN_SECONDS ); if ( $days >= self::DORMANT_MARKER_DAYS ) { $flags['dormant_days'] = $days; } } return $flags; } /** * The most recent earlier login whose location can be one end of a * journey: a country, not marked anonymising. * * @param array> $earlier Earlier logins, newest first. * @return array{country: string, time: int}|null */ private static function last_known_location( array $earlier ): ?array { foreach ( $earlier as $row ) { $country = $row['country'] ?? null; $time = self::utc_time( $row['timestamp'] ?? null ); if ( ! is_string( $country ) || '' === $country || null === $time ) { continue; } $details = is_string( $row['details'] ?? null ) ? json_decode( $row['details'], true ) : null; if ( is_array( $details ) && true === ( $details['anonymous_network'] ?? null ) ) { continue; } return array( 'country' => $country, 'time' => $time, ); } return null; } /** * A stored timestamp (UTC, "Y-m-d H:i:s") as Unix time, or null. * * @param mixed $timestamp The column value. */ private static function utc_time( $timestamp ): ?int { if ( ! is_string( $timestamp ) || '' === $timestamp ) { return null; } $time = strtotime( $timestamp . ' UTC' ); return false === $time ? null : $time; } /** * "class|browser family|os family", or null without a device class. * * @param array $row A row or a context. */ public static function device_key( array $row ): ?string { $class = $row['device_class'] ?? null; if ( ! is_string( $class ) || '' === $class ) { return null; } return $class . '|' . self::without_version( $row['browser'] ?? '' ) . '|' . self::without_version( $row['os'] ?? '' ); } /** * A label without its trailing version: "Chrome 140" is "Chrome", * "Windows 10/11" is "Windows", "Samsung Internet 28" is "Samsung Internet". * * @param mixed $label A browser or OS label. */ private static function without_version( $label ): string { return trim( (string) preg_replace( '#\s+[\d./]+$#', '', is_string( $label ) ? $label : '' ) ); } /** * The user's most recent logins before this one. * * @param int $user_id User. * @return array> */ private static function earlier_logins( int $user_id ): array { global $wpdb; $query = 'SELECT timestamp, country, device_class, browser, os, details FROM ' . Database::get_table_name() . ' WHERE user_id = %d AND event_type = %s ORDER BY id DESC LIMIT %d'; // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- the table name is the plugin's own; a login must see the rows as they are now. $rows = $wpdb->get_results( $wpdb->prepare( $query, $user_id, 'user_login', self::LOOKBACK ), ARRAY_A ); return is_array( $rows ) ? $rows : array(); } }