Invision Power Services, Inc. * @copyright (c) Invision Power Services, Inc. * @license https://www.invisioncommunity.com/legal/standards/ * @package Invision Community * @since 6 September 2017 */ namespace IPS\Session\Store; /* To prevent PHP errors (extending class does not exist) revealing path */ use IPS\Member; use IPS\Member\Group; use IPS\Redis as RedisClass; use IPS\Session\Front; use IPS\Session\Store; use IPS\Settings; use RedisException; use function array_slice; use function count; use function defined; use function in_array; use function is_array; use function is_null; use const IPS\TEXT_ENCRYPTION_KEY; if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) ) { header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' ); exit; } /** * Redis Session Handler */ class Redis extends Store { /** * @brief Default expiration for keys in seconds */ static protected int $ttl = 1800; #30 mins /** * Return the hash we will use to obfuscate the session ID * * @return string */ public static function getHash(): string { if ( TEXT_ENCRYPTION_KEY ) { return TEXT_ENCRYPTION_KEY; } return Settings::i()->sql_pass; } /** * Load the session from the storage engine * * @param string $sessionId Session ID * @return array|NULL */ public function loadSession( string $sessionId ): ?array { if ( $result = RedisClass::i()->hGetAll( static::_key( 'session_id_' . md5( $sessionId . static::getHash() ) ) ) ) { try { return RedisClass::i()->decode( $result['data'] ); } catch( RedisException $e ){} } return NULL; } /** * Update the session storage engine * * @param array $data Session data to store * @return void */ public function updateSession( array $data ) : void { /* Groups are loaded into memory so this does not cause a query */ $group = Group::load( $data['member_group'] ); /* Update the specific session */ RedisClass::i()->del( static::_key( 'session_id_' . md5( $data['id'] . static::getHash() ) ) ); RedisClass::i()->hMSet( static::_key( 'session_id_' . md5( $data['id'] . static::getHash() ) ), array( 'member_id' => $data['member_id'], 'member_name' => $data['member_name'], 'seo_name' => $data['seo_name'], 'member_group' => $data['member_group'], 'login_type' => $data['login_type'], 'in_editor' => $data['in_editor'], 'data' => RedisClass::i()->encode( $data ) ), static::$ttl ); /* Update the list of sessions for the online list [ microtime => sessionID ] */ RedisClass::i()->zAdd( static::_key( 'session_map' ), time(), 'session_id_' . md5( $data['id'] . static::getHash() ), static::$ttl ); /* Update users list */ if ( $data['uagent_type'] == 'search' ) { /* Make a unique row based on IP and user-agent to prevent multiple rows for each spider */ RedisClass::i()->zAdd( static::_key( 'session_online_spiders' ), time(), md5( $data['ip_address'] . $data['browser'] ), static::$ttl ); } else if ( $data['member_id'] ) { RedisClass::i()->zAdd( static::_key( 'session_online_users' ), time(), $data['member_id'] . '__' . 'session_id_' . md5( $data['id'] . static::getHash() ), static::$ttl ); } else { /* A guest may have one ip address but multiple devices, but we don't really need to track that */ RedisClass::i()->zAdd( static::_key( 'session_online_guests' ), time(), md5( $data['ip_address'] ), static::$ttl ); } /* Delete old items */ if ( ! RedisClass::i()->get( static::_key( 'session_cleanup' ) ) ) { /* Do a little clean up */ RedisClass::i()->zRemRangeByScore( static::_key( 'session_map' ), 0, time() - static::$ttl ); RedisClass::i()->zRemRangeByScore( static::_key( 'session_online_spiders' ), 0, time() - static::$ttl ); RedisClass::i()->zRemRangeByScore( static::_key( 'session_online_users' ), 0, time() - static::$ttl ); RedisClass::i()->zRemRangeByScore( static::_key( 'session_online_guests' ), 0, time() - static::$ttl ); /* And do it again in 3ish mins */ RedisClass::i()->setEx( static::_key( 'session_cleanup' ), 180, time() ); } } /** * Delete from the session engine * * @param string $sessionId Session ID * @return void */ public function deleteSession( string $sessionId ) : void { $data = $this->loadSession( $sessionId ); RedisClass::i()->del( static::_key( 'session_id_' . md5( $sessionId . static::getHash() ) ) ); RedisClass::i()->zRem( static::_key( 'session_map' ), 'session_id_' . md5( $sessionId . static::getHash() ) ); RedisClass::i()->zRem( static::_key( 'session_online_spiders' ), md5( $data['ip_address'] . $data['browser'] ) ); RedisClass::i()->zRem( static::_key( 'session_online_users' ), $data['member_id'] . '__' . 'session_id_' . md5( $data['id'] . static::getHash() ) ); RedisClass::i()->zRem( static::_key( 'session_online_guests' ), md5( $data['ip_address'] ) ); } /** * Delete from the session engine * * @param int $memberId You can probably figure this out right? * @param string|NULL $userAgent User Agent [optional] * @param array|NULL $keepSessionIds Array of session ids to keep [optional] * @return void */ public function deleteByMember( int $memberId, string $userAgent=NULL, array $keepSessionIds=NULL ) : void { if ( ! is_array( $keepSessionIds ) and ! is_null( $keepSessionIds ) ) { $keepSessionIds = array( $keepSessionIds ); } $sessionMap = RedisClass::i()->zRange( static::_key( 'session_online_users' ), 0, -1 ); foreach( $sessionMap as $index => $redisKey ) { $key = explode( '__', $redisKey ); $session = RedisClass::i()->hMGet( $key[1], array( 'data' ) ); try { $sessionMap[ $index ] = RedisClass::i()->decode( $session['data'] ); } catch( RedisException $e ){} } foreach( $sessionMap as $session ) { /* SessionMap may not link to a valid session, remove if that is the case */ if( !is_array( $session ) ) { RedisClass::i()->zRem( static::_key( 'session_map' ), $session ); continue; } $delete = false; if ( $session['member_id'] == $memberId ) { $delete = true; } if ( $userAgent and $userAgent != $session['browser'] ) { $delete = false; } if ( $keepSessionIds and in_array( $session['id'], $keepSessionIds ) ) { $delete = false; } if ( $delete ) { $this->deleteSession( $session['id'] ); } } } /** * Fetch all active session keys * * @return array of session IDs */ public function getSessionIds(): array { if ( $result = RedisClass::i()->zRangeByScore( static::_key( 'session_map' ), '-inf', '+inf', array( 'withscores' => false ) ) ) { return $result; } return array(); } /** * Delete from the session engine * * @param int $memberId You can probably figure this out right? * @return array|FALSE */ public function getLatestMemberSession( int $memberId ): array|FALSE { $redis = RedisClass::i()->zRevRangeByScore( static::_key( 'session_online_users' ), '+inf', '-inf', array('withscores' => FALSE, 'alpha' => TRUE ) ); if( is_array( $redis ) ) { foreach ( $redis as $data ) { [ $id, $sessionKey ] = explode( '__', $data ); if ( $id == $memberId ) { if ( $result = RedisClass::i()->hGetAll( static::_key( $sessionKey ) ) ) { try { return RedisClass::i()->decode( $result['data'] ); } catch ( RedisException $e ) {} } } } } return FALSE; } /** * Clear sessions - abstracted so it can be called externally without initiating a session * * @param int $timeout Sessions older than the number of seconds provided will be deleted * @return void */ public static function clearSessions( int $timeout ) : void { /* Remove the public facing items. This is only called by PHP's session gc so individual sessions do not need removing as they are cleaned by Redis' TTL */ RedisClass::i()->zRemRangeByScore( static::_key( 'session_map' ), 0, time() - $timeout ); RedisClass::i()->zRemRangeByScore( static::_key( 'session_online_spiders' ), 0, time() - $timeout ); RedisClass::i()->zRemRangeByScore( static::_key( 'session_online_users' ), 0, time() - $timeout ); RedisClass::i()->zRemRangeByScore( static::_key( 'session_online_guests' ), 0, time() - $timeout ); RedisClass::i()->del( static::_key( 'session_onlinelist' ) ); } /** * Redis key */ protected static ?string $_redisKey = NULL; /** * Returns a key to be stored with Redis * * @param string $key Key suffix * @return string */ protected static function _key( string $key ): string { /* Only manage the session_onlist key which is prone to corruption. We don't want to wipe out the online list each time this file fails */ if ( $key == 'session_onlinelist' ) { if ( !static::$_redisKey ) { /* Last access ensures that the data is not stale if we fail back to MySQL and then go back to Redis later */ if ( !( static::$_redisKey = RedisClass::i()->get( 'redisKey_session' ) ) OR ! RedisClass::i()->get( 'redisStore_lastAccess' ) ) { static::$_redisKey = md5( mt_rand() ); RedisClass::i()->setex( 'redisKey_session', 604800, static::$_redisKey ); RedisClass::i()->setex( 'redisStore_lastAccess', ( 3 * 3600 ), time() ); } } return static::$_redisKey . '_' . $key; } else { return $key; } } /** * Resets the session key to force ignore any previous redis data stored * * @return void */ protected static function resetKey() : void { static::$_redisKey = md5( mt_rand() ); RedisClass::i()->setex( 'redisKey_session', 604800, static::$_redisKey ); } /** * Redis key */ protected int $fetchAttempt = 0; /** * @var array|null cache online user data after fetching it */ protected ?array $onlineUsers = null; /** * Fetch all online users (but not spiders) * * @param int $flags Bitwise flags * @param string $sort Sort direction * @param array|null $limit Limit [ offset, limit ] * @param int|null $memberGroup Limit by a specific member group ID * @param boolean $showAnonymous Show anonymously logged in peoples? * @return array|int */ public function getOnlineUsers( int $flags=0, string $sort='desc', array $limit=NULL, int $memberGroup=NULL, bool $showAnonymous=FALSE ): array|int { if( $this->onlineUsers === NULL ) { try { $this->onlineUsers = RedisClass::i()->lRange( static::_key( 'session_onlinelist' ), 0, -1 ); } catch ( RedisException $e ) { /* Something went wrong, so reset the key to force a new file */ static::resetKey(); } } if ( !$this->onlineUsers ) { /* Ensure file is deleted */ try { RedisClass::i()->del( static::_key( 'session_onlinelist' ) ); } catch ( RedisException $e ) { /* Something went wrong, so reset the key to force a new file */ static::resetKey(); } $options = array( 'sort' => $sort === 'asc' ? 'asc' : 'desc', 'store' => RedisClass::i()->prefix . static::_key( 'session_onlinelist' ), 'alpha' => true, 'by' => 'nosort ' . $sort === 'asc' ? 'asc' : 'desc', 'ttl' => 30, /* This ensures the stored file session_online only lasts for 30 seconds */ 'get' => array( static::_key( RedisClass::i()->prefix ) . '*->member_id', static::_key( RedisClass::i()->prefix ) . '*->member_name', static::_key( RedisClass::i()->prefix ) . '*->seo_name', static::_key( RedisClass::i()->prefix ) . '*->member_group', static::_key( RedisClass::i()->prefix ) . '*->login_type', static::_key( RedisClass::i()->prefix ) . '*->data' ) ); RedisClass::i()->sort( static::_key( 'session_map' ), $options ); try { /* Force expiration in 60 seconds to prevent stale caches hanging around */ RedisClass::i()->expire( static::_key( 'session_onlinelist' ), 60 ); $this->onlineUsers = RedisClass::i()->lRange( static::_key( 'session_onlinelist' ), 0, -1 ); } catch ( RedisException $e ) { $this->fetchAttempt++; /* Something went wrong, so reset the key to force a new file */ static::resetKey(); if ( $this->fetchAttempt < 2 ) { /* And try again, but only once more to prevent an infinite loop */ return $this->getOnlineUsers($flags, $sort, $limit, $memberGroup, $showAnonymous); } else { $this->onlineUsers = []; } } } /* Reset the fetch attempt */ $this->fetchAttempt = 0; /* Sort returns a flat array, so [ 1, matt, matt, 4, 0, 2, Joe, joe, 3, 0 .. ] so we need to build that into an associative array we can work with */ $return = array(); $i = 0; while( $i < count( $this->onlineUsers ) ) { $fields = array(); $data = NULL; foreach( array( 'member_id', 'member_name', 'seo_name', 'member_group', 'login_type', 'data' ) as $field ) { if ( $field === 'data' ) { try { $data = RedisClass::i()->decode( $this->onlineUsers[ $i++ ] ); } catch( RedisException $e ) { $data = NULL; } } /* login_type must be cast as an integer or else anonymous state can be lost when adjustSessions() runs */ elseif( $field === 'login_type' ) { $fields[ $field ] = (int) $this->onlineUsers[ $i++ ]; } else { $fields[ $field ] = $this->onlineUsers[ $i++ ]; } } if ( is_array( $data ) AND count( $data ) ) { /* Have we already fetched this member? */ if ( $fields['member_id'] and isset( $return[ $fields['member_id'] ] ) ) { continue; } /* Cut off is 30 mins */ if ( $data['running_time'] < ( time() - ( 30 * 60 ) ) ) { continue; } /* Ignore spiders */ if ( ! $fields['member_id'] and ! ( $data['login_type'] == Front::LOGIN_TYPE_GUEST or $data['login_type'] == Front::LOGIN_TYPE_INCOMPLETE ) ) { continue; } $return[ $fields['member_id'] ?: $data['ip_address'] ] = array_merge( $data, $fields ); } } if ( $flags ) { $members = array(); foreach( $return as $id => $data ) { /* No members */ if ( ! ( $flags & static::ONLINE_MEMBERS ) ) { if ( $data['member_id'] ) { continue; } } if ( ! ( $flags & static::ONLINE_GUESTS ) ) { if ( ! $data['member_id'] ) { continue; } } if ( $memberGroup and $data['member_group'] != $memberGroup ) { continue; } if ( ! $showAnonymous and $data['login_type'] == Front::LOGIN_TYPE_ANONYMOUS and ( !Member::loggedIn()->member_id OR $data['member_id'] != Member::loggedIn()->member_id ) ) { continue; } $members[ $id ] = $data; } /* Count only? */ if ( $flags & static::ONLINE_COUNT_ONLY ) { return count( $members ); } /* Hooray for PHP 7 */ usort($members, function ($m1, $m2 ) { return $m2['running_time'] <=> $m1['running_time']; } ); if ( $limit ) { return array_slice( $members, $limit[0], $limit[1], TRUE ); } return $members; } return $return; } /** * Fetch all members active at a specific location * * @param string $app Application directory (core, forums, etc) * @param string $module Module * @param string $controller Controller * @param int $id Current item ID (empty if none) * @param string $url Current viewing URL * @return array */ public function getOnlineMembersByLocation( string $app, string $module, string $controller, ?int $id, string $url ): array { $members = array(); foreach($this->getOnlineUsers(static::ONLINE_MEMBERS, 'desc') as $member ) { if ( $member['current_appcomponent'] == $app and $member['current_module'] == $module and $member['current_controller'] == $controller and $member['current_id'] == $id ) { if ( $url and mb_stristr( $member['location_url'], $url ) ) { $members[ $member['member_id'] ] = array( 'member_id' => $member['member_id'], 'member_name' => $member['member_name'], 'seo_name' => $member['seo_name'], 'member_group' => $member['member_group'], 'login_type' => $member['login_type'], 'in_editor' => $member['in_editor'] ); } } } return $members; } }