>, total: int, pages: int} */ public static function get_active_sessions( array $args = [] ): array { $page = max( 1, (int) ( $args['page'] ?? 1 ) ); $per_page_raw = isset( $args['per_page'] ) ? (int) $args['per_page'] : 0; $per_page = $per_page_raw > 0 ? $per_page_raw : max( 1, (int) get_option( self::PER_PAGE_OPTION, self::DEFAULT_PER_PAGE ) ); $search = sanitize_text_field( $args['search'] ?? '' ); $role = sanitize_text_field( $args['role'] ?? '' ); $multi_only = ! empty( $args['multi_only'] ); $all_sessions = self::collect_all_sessions( $search, $role ); // Filter to users who have more than one active session when requested. if ( $multi_only ) { $counts = []; foreach ( $all_sessions as $s ) { $counts[ $s['user_id'] ] = ( $counts[ $s['user_id'] ] ?? 0 ) + 1; } $all_sessions = array_values( array_filter( $all_sessions, static fn( array $s ) => ( $counts[ $s['user_id'] ] ?? 0 ) > 1 ) ); } $total = count( $all_sessions ); $pages = $total > 0 ? (int) ceil( $total / $per_page ) : 1; $offset = ( $page - 1 ) * $per_page; $paged = array_slice( $all_sessions, $offset, $per_page ); return [ 'sessions' => $paged, 'total' => $total, 'pages' => $pages, ]; } /** * Collect and format all active sessions from WP user meta, with optional filtering. * * @param string $search Optional username/display-name search string. * @param string $role Optional role filter. * @return array> */ private static function collect_all_sessions( string $search = '', string $role = '' ): array { global $wpdb; $current_hash = self::get_current_session_hash(); // Build user query args — only users who have active session tokens. // cache_results: false ensures we always get a live DB count, not a stale cached list. $user_query_args = [ 'meta_key' => 'session_tokens', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_compare' => 'EXISTS', 'number' => 999, 'fields' => 'all_with_meta', 'cache_results' => false, 'update_meta_cache' => false, ]; if ( ! empty( $search ) ) { $user_query_args['search'] = '*' . $search . '*'; $user_query_args['search_columns'] = [ 'user_login', 'display_name', 'user_email' ]; } if ( ! empty( $role ) ) { $user_query_args['role'] = $role; } $user_query = new WP_User_Query( $user_query_args ); $users = $user_query->get_results(); if ( empty( $users ) ) { return []; } $now = time(); $sessions = []; $status_cache = []; foreach ( $users as $user ) { // Clear cached user meta for this user to guarantee a fresh DB read on every poll. wp_cache_delete( $user->ID, 'user_meta' ); $raw_sessions = get_user_meta( $user->ID, 'session_tokens', true ); if ( ! is_array( $raw_sessions ) || empty( $raw_sessions ) ) { continue; } // Get primary role for display. $roles = (array) $user->roles; $user_role = ! empty( $roles ) ? $roles[0] : ''; foreach ( $raw_sessions as $token_hash => $session ) { // Skip expired sessions. if ( empty( $session['expiration'] ) || (int) $session['expiration'] <= $now ) { continue; } $login_ts = isset( $session['login'] ) ? (int) $session['login'] : 0; $expiration_ts = isset( $session['expiration'] ) ? (int) $session['expiration'] : 0; $ip_raw = isset( $session['ip'] ) ? (string) $session['ip'] : ''; $ua = isset( $session['ua'] ) ? (string) $session['ua'] : ''; $parsed_ua = self::parse_user_agent( $ua ); if ( ! isset( $status_cache[ $user->ID ] ) ) { $status_cache[ $user->ID ] = class_exists( 'ACTLOGPRO_Live_Sessions_Activity' ) ? ACTLOGPRO_Live_Sessions_Activity::get_status_for_user( $user->ID ) : [ 'status' => 'idle', 'label' => __( 'Idle', 'activity-log-pro' ), 'tooltip' => '', ]; } $sessions[] = [ 'token_hash' => $token_hash, 'user_id' => $user->ID, 'user_login' => $user->user_login, 'display_name' => $user->display_name, 'user_role' => $user_role, 'avatar_url' => get_avatar_url( $user->ID, [ 'size' => 32 ] ), 'ip_address' => self::mask_ip( $ip_raw ), 'ip_raw' => $ip_raw, 'login_time' => $login_ts > 0 ? date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $login_ts ) : __( 'Unknown', 'activity-log-pro' ), 'login_timestamp' => $login_ts, 'expiration_time' => $expiration_ts > 0 ? date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $expiration_ts ) : __( 'Unknown', 'activity-log-pro' ), 'expiration_timestamp' => $expiration_ts, 'browser' => $parsed_ua['browser'], 'os' => $parsed_ua['os'], 'user_agent' => $ua, 'is_current' => ( $token_hash === $current_hash ), 'activity_url' => self::get_activity_link( $user->ID, $login_ts ), 'activity_status' => $status_cache[ $user->ID ], ]; } } // Sort by login time descending (most recent first). usort( $sessions, static function ( array $a, array $b ): int { return $b['login_timestamp'] <=> $a['login_timestamp']; } ); return $sessions; } // ------------------------------------------------------------------------- // IP masking // ------------------------------------------------------------------------- /** * Apply IP masking to a stored session IP, matching the plugin's existing privacy settings. * Full IP is shown only when premium is enabled and the full-IP option is active. * * @param string $ip Raw IP address from session meta. * @return string Masked or full IP address. */ public static function mask_ip( string $ip ): string { if ( empty( $ip ) ) { return ''; } $should_hide = ! ACTLOGPRO_Premium_Helper::is_premium_enabled() || get_option( 'actlogpro_store_full_ip', '0' ) === '0'; if ( $should_hide ) { if ( strpos( $ip, ':' ) !== false ) { // IPv6 — hide last group. return substr( $ip, 0, strrpos( $ip, ':' ) ) . ':xxxx'; } // IPv4 — hide last octet. return substr( $ip, 0, strrpos( $ip, '.' ) ) . '.xxx'; } return $ip; } // ------------------------------------------------------------------------- // User agent parsing // ------------------------------------------------------------------------- /** * Parse a user agent string into browser name and OS/platform. * * Uses lightweight string matching — no external library required. * * @param string $ua User agent string. * @return array{browser: string, os: string} */ public static function parse_user_agent( string $ua ): array { if ( empty( $ua ) ) { return [ 'browser' => __( 'Unknown', 'activity-log-pro' ), 'os' => __( 'Unknown', 'activity-log-pro' ) ]; } // Detect browser. $browser = __( 'Other', 'activity-log-pro' ); if ( strpos( $ua, 'Edg/' ) !== false || strpos( $ua, 'Edge/' ) !== false ) { $browser = 'Edge'; } elseif ( strpos( $ua, 'OPR/' ) !== false || strpos( $ua, 'Opera' ) !== false ) { $browser = 'Opera'; } elseif ( strpos( $ua, 'Chrome/' ) !== false && strpos( $ua, 'Chromium/' ) === false ) { $browser = 'Chrome'; } elseif ( strpos( $ua, 'Firefox/' ) !== false ) { $browser = 'Firefox'; } elseif ( strpos( $ua, 'Safari/' ) !== false && strpos( $ua, 'Chrome' ) === false ) { $browser = 'Safari'; } elseif ( strpos( $ua, 'MSIE' ) !== false || strpos( $ua, 'Trident/' ) !== false ) { $browser = 'Internet Explorer'; } // Detect OS. $os = __( 'Other', 'activity-log-pro' ); if ( strpos( $ua, 'Windows NT' ) !== false ) { $os = 'Windows'; } elseif ( strpos( $ua, 'iPhone' ) !== false || strpos( $ua, 'iPad' ) !== false ) { $os = 'iOS'; } elseif ( strpos( $ua, 'Android' ) !== false ) { $os = 'Android'; } elseif ( strpos( $ua, 'Mac OS X' ) !== false || strpos( $ua, 'Macintosh' ) !== false ) { $os = 'macOS'; } elseif ( strpos( $ua, 'Linux' ) !== false ) { $os = 'Linux'; } return [ 'browser' => $browser, 'os' => $os ]; } // ------------------------------------------------------------------------- // Current session identification // ------------------------------------------------------------------------- /** * Return the SHA-256 hash of the current user's session token. * Matches the hash key stored in session_tokens user meta by WP core. * * @return string Token hash, or empty string if no session token available. */ public static function get_current_session_hash(): string { $token = wp_get_session_token(); if ( empty( $token ) ) { return ''; } return function_exists( 'hash' ) ? hash( 'sha256', $token ) : sha1( $token ); } // ------------------------------------------------------------------------- // Activity log cross-link // ------------------------------------------------------------------------- /** * Build a URL to the main activity log filtered to a specific user+session. * Allows admins to see everything that user did since their login. * * @param int $user_id Target user ID. * @param int $login_timestamp Unix timestamp of session login time. * @return string Admin URL with pre-applied filters. */ public static function get_activity_link( int $user_id, int $login_timestamp ): string { if ( ! $user_id ) { return ''; } // Use alp_ prefixed params so alp-admin.js pre-fills the filter dropdowns on page load. $args = [ 'page' => 'activity-log-pro', 'alp_user' => $user_id, ]; if ( $login_timestamp > 0 ) { $args['alp_date_from'] = gmdate( 'Y-m-d', $login_timestamp ); } return add_query_arg( $args, admin_url( 'admin.php' ) ); } // ------------------------------------------------------------------------- // Activity log writer // ------------------------------------------------------------------------- /** * Write a session management action to the main activity log. * * @param string $action Action taken (session_terminated, session_lockdown, session_blocked). * @param int $target_user_id The user whose session was affected. * @param string $target_login Target user's login name. * @param array $session_data Extra context (IP, browser, login_time). * @return void */ public static function log_session_action( string $action, int $target_user_id, string $target_login, array $session_data = [] ): void { if ( ! class_exists( 'ACTLOGPRO_Database' ) ) { return; } $actor = wp_get_current_user(); $actor_ip = class_exists( 'ACTLOGPRO_Logger' ) ? ACTLOGPRO_Logger::get_ip_address() : ''; $descriptions = [ 'session_terminated' => sprintf( /* translators: 1: target username, 2: admin username */ __( 'Session Terminated: Session for %1$s was terminated by %2$s', 'activity-log-pro' ), $target_login, $actor->user_login ), 'session_lockdown' => sprintf( /* translators: 1: admin username */ __( 'Emergency Lockdown: All sessions (except current) terminated by %s', 'activity-log-pro' ), $actor->user_login ), 'session_blocked' => sprintf( /* translators: 1: target username */ __( 'Session Blocked: Simultaneous login blocked for %s', 'activity-log-pro' ), $target_login ), ]; $description = $descriptions[ $action ] ?? sprintf( /* translators: 1: action name, 2: target username */ __( 'Session Action: %1$s for %2$s', 'activity-log-pro' ), $action, $target_login ); $metadata = wp_json_encode( [ 'target_ip' => $session_data['ip_raw'] ?? '', 'target_browser' => $session_data['browser'] ?? '', 'target_os' => $session_data['os'] ?? '', 'login_time' => $session_data['login_time'] ?? '', ] ); ACTLOGPRO_Database::log_activity( [ 'user_id' => $actor->ID, 'user_login' => $actor->user_login, 'user_display_name' => $actor->display_name, 'user_role' => ! empty( $actor->roles ) ? $actor->roles[0] : '', 'ip_address' => $actor_ip, 'event_type' => 'session', 'object_type' => 'user', 'object_id' => $target_user_id, 'object_name' => $target_login, 'action' => $action, 'description' => $description, 'metadata' => $metadata, 'created_at' => current_time( 'mysql' ), ] ); } }