Files
IPB/system/Redis/Redis.php
T
2025-12-19 16:00:35 -08:00

573 lines
15 KiB
PHP

<?php
/**
* @brief Redis Engine Class
* @author <a href='https://www.invisioncommunity.com'>Invision Power Services, Inc.</a>
* @copyright (c) Invision Power Services, Inc.
* @license https://www.invisioncommunity.com/legal/standards/
* @package Invision Community
* @since 11 Sept 2017
*/
namespace IPS;
/* To prevent PHP errors (extending class does not exist) revealing path */
if ( !\defined( '\IPS\SUITE_UNIQUE_KEY' ) )
{
header( ( isset( $_SERVER['SERVER_PROTOCOL'] ) ? $_SERVER['SERVER_PROTOCOL'] : 'HTTP/1.0' ) . ' 403 Forbidden' );
exit;
}
/**
* Redis Cache Class
*/
class _Redis
{
/**
* @brief Multiton Store
*/
protected static $multitons = array();
/**
* @brief Connections Store
*/
protected static $connections = array();
/**
* @brief Default expiration for keys in seconds
*/
protected static $ttl = 604800; #7 days
/**
* @brief Log what redis is up to
*/
public static $log = array();
/**
* @brief Log what redis is up to
*/
public $prefix = NULL;
/**
* @brief Unpack the config once
*/
protected static $config = NULL;
/**
* Writes made
*/
protected static $writes = array();
/**
* Get instance
*
* @param NULL|array $configuration Configuration to use (NULL to use \IPS\REDIS_CONFIG or \IPS\CACHE_CONFIG)
* @param NULL|string $identifier Identifier (to support multiple instances)
* @return \Redis
*/
public static function i( $configuration=NULL, $identifier=NULL )
{
if ( static::$config === NULL )
{
$config = ( \defined( '\IPS\REDIS_CONFIG' ) and \IPS\REDIS_CONFIG !== NULL ) ? \IPS\REDIS_CONFIG : \IPS\CACHE_CONFIG;
static::$config = $configuration ?: json_decode( $config, true );
}
$identifier = $identifier ? $identifier : '_MAIN';
if ( ! isset( static::$multitons[ $identifier ] ) )
{
static::$multitons[ $identifier ] = new static;
/* Set the prefix with the most obvious comment in the world */
static::$multitons[ $identifier ]->prefix = \IPS\SUITE_UNIQUE_KEY . '_';
}
/* Return */
return static::$multitons[ $identifier ];
}
/**
* Destructor
*
* @return void
*/
public function __destruct()
{
if ( \IPS\DEBUG_LOG and \count( static::$writes ) > 50 )
{
$slice = implode( ', ', \array_slice( static::$writes, 0, 50 ) );
\IPS\Log::debug( "Large number of Redis writes: " . $slice, 'redis_writes' );
}
}
/**
* @brief Chosen reader for this session
*/
protected static $reader = NULL;
/**
* Connect to Redis
*
* @param string|NULL $identifier Identifier
* @return \Redis
* @throws \RedisException If connection cannot be established
* @throws \BadMethodCallException If Redis PHP extension is not installed
*/
public function connection( $identifier=NULL )
{
if ( ! class_exists('Redis') )
{
throw new \BadMethodCallException;
}
$useConfig = NULL;
if ( isset( static::$config['write'] ) )
{
/* We have multiple servers for read and one for write */
if ( $identifier === 'write' )
{
$useConfig = static::$config['write'];
}
else if ( $identifier === 'read' )
{
if ( static::$reader === NULL )
{
static::$reader = rand( 0, \count( static::$config['read'] ) - 1 );
}
$useConfig = static::$config['read'][ static::$reader ];
$identifier = 'read' . static::$reader;
}
else
{
/* Set up the writer first as the default server */
$identifier = 'write';
$useConfig = static::$config['write'];
}
}
else
{
/* We have only passed through one server */
$identifier = 'single';
$useConfig = static::$config;
}
if ( ! isset( static::$connections[ $identifier ] ) )
{
try
{
static::$connections[ $identifier ] = new \Redis;
/* PHP Redis uses many PHP internals to connect, and these can throw ErrorException when they fail but we want a consistent exception */
if( @static::$connections[ $identifier ]->connect( $useConfig['server'], $useConfig['port'], 2 ) === FALSE )
{
unset( static::$connections[ $identifier ] );
throw new \RedisException('CANNOT_CONNECT');
}
else
{
if( isset( $useConfig['password'] ) and $useConfig['password'] )
{
if( static::$connections[ $identifier ]->auth( $useConfig['password'] ) === FALSE )
{
unset( static::$connections[ $identifier ] );
throw new \RedisException;
}
}
}
if( static::$connections[ $identifier ] !== NULL )
{
static::$connections[ $identifier ]->setOption( \Redis::OPT_SERIALIZER, \Redis::SERIALIZER_NONE );
static::$connections[ $identifier ]->setOption( \Redis::OPT_PREFIX, $this->prefix );
}
/* If connection times out, connect can return TRUE and we won't know until our next attempt to talk to the server,
so we should ping now to verify we were able to connect successfully */
static::$connections[ $identifier ]->ping();
if ( \IPS\REDIS_LOG )
{
static::$log[ sprintf( '%.4f', microtime(true) ) ] = array( 'redis', "Redis connected (" . $identifier . ' ' . $useConfig['server'] . ")" );
}
if ( \count( static::$connections ) === 1 )
{
register_shutdown_function( function( $object ){
try
{
/* First we have to make sure sessions have written */
if( \IPS\Session\Store::i() instanceof \IPS\Session\Store\Redis )
{
session_write_close();
}
foreach( static::$connections as $key => $connection )
{
$connection->close();
}
/* Reset stored connections so they can be re-connected correctly if tasks run after this shutdown proceses */
static::$connections = array();
}
catch( \RedisException $e ){}
}, $this );
}
}
catch( \RedisException $e )
{
/* Unset this connection */
if ( isset( static::$connections[ $identifier ] ) )
{
unset( static::$connections[ $identifier ] );
}
$this->resetConnection( $e );
}
}
if( !isset( static::$connections[ $identifier ] ) )
{
throw new \RedisException('CANNOT_CONNECT');
}
return static::$connections[ $identifier ];
}
/**
* Call methods
*
* @param string $method Method
* @param mixed $args Arguments
* @return mixed
*/
public function __call( $method, $args )
{
if ( method_exists( 'Redis', $method ) )
{
$type = ( \stristr( $method, 'get' ) or \stristr( $method, 'RevRange' ) ) ? 'read' : 'write';
$return = $this->connection( $type )->$method( ...$args );
if ( $type === 'write' and \count( $args ) )
{
static::$writes[] = $args[0];
}
if ( \IPS\REDIS_LOG and \count( $args ) )
{
if ( preg_match( '#^[a-f0-9]{32}_str__#', $args[0] ) )
{
$args[0] = preg_replace( '#^[a-f0-9]{32}_str__#', '[DATASTORE] ', $args[0] );
}
static::$log[ sprintf( '%.4f', microtime(true) ) ] = array( 'redis', "({$type}) {$method} " . $args[0], json_encode( $args ) );
}
return $return;
}
}
/**
* Add one or more members to a sorted set or update its score if it already exists
* Overloaded here so it can add a TTL to prevent permanent keys
*
* @param string $key Key
* @param float $score Score
* @param string $value Value
* @param int|NULL $ttl TTL in seconds
* @return 1 if the element is added. 0 otherwise.
*/
public function zAdd( $key, $score, $value, $ttl=NULL )
{
$return = $this->connection('write')->zAdd( $this->key( $key ), $score, $value );
$this->connection('write')->expire( $this->key( $key ), ( $ttl ? $ttl : static::$ttl ) );
if ( \IPS\REDIS_LOG )
{
static::$log[ sprintf( '%.4f', microtime(true) ) ] = array( 'redis', "(write) zAdd " . $key . " = " . $return, json_encode( $value ) );
}
return $return;
}
/**
* Fills in a whole hash. Non-string values are converted to string, using the standard (string) cast. NULL values are stored as empty strings.
* Overloaded here so it can add a TTL to prevent permanent keys
*
* @param string $key Key
* @param array $value Value
* @param int|NULL $ttl TTL in seconds
* @return boolean
*/
public function hMSet( $key, $value, $ttl=NULL )
{
$return = $this->connection('write')->hMSet( $this->key( $key ), $value );
$this->connection('write')->expire( $this->key( $key ), ( $ttl ? $ttl : static::$ttl ) );
if ( \IPS\REDIS_LOG )
{
static::$log[ sprintf( '%.4f', microtime(true) ) ] = array( 'redis', "(write) hMSet " . $key . " = " . $return, json_encode( $value ) );
}
return $return;
}
/**
* Set the string value in argument as value of the key, with a time to live
* Overloaded here so it can be logged
*
* @param string $key Key
* @param int|NULL $ttl TTL in seconds
* @param string $value Value
* @return boolean
*/
public function setEx( $key, $ttl, $value )
{
$return = $this->connection('write')->setEx( $this->key( $key ), $ttl, $value );
if ( \IPS\REDIS_LOG )
{
static::$log[ sprintf( '%.4f', microtime(true) ) ] = array( 'redis', "(write) setEx " . $key . " = " . $return, json_encode( $value ) );
}
return $return;
}
/**
* Sort the elements in a list, set or sorted set.
* Overloaded here so we can adjust the key
*
* @param string $key Key
* @param array $options Options: array(key => value, ...) - optional
* @return array
*/
public function sort( $key, $options=array() )
{
$return = $this->connection('write')->sort( $this->key( $key ), $options );
if ( isset( $options['store'] ) )
{
$this->connection('write')->expire( $this->key( $options['store'] ), ( isset( $options['ttl'] ) and $options['ttl'] ) ? $options['ttl'] : static::$ttl );
}
if ( \IPS\REDIS_LOG )
{
static::$log[ sprintf( '%.4f', microtime(true) ) ] = array( 'redis', "(write) sort " . $key, json_encode( $return ) );
}
return $return;
}
/**
* Returns the whole hash, as an array of strings indexed by strings.
* Overloaded here so it can be logged
*
* @param string $key Key
* @return array
*/
public function hGetAll( $key )
{
/* Make sure we read */
$return = $this->connection('read')->hGetAll( $this->key( $key ) );
if ( \IPS\REDIS_LOG )
{
static::$log[ sprintf( '%.4f', microtime(true) ) ] = array( 'redis', "(read) hGetAll " . $key, json_encode( $return ) );
}
return $return;
}
/**
* Publish a message to Redis PubSub
*
* @param string $key The message key
* @param mixed $value The payload
* @param boolean $encrypted Should the payload be encrypted? Has no effect in non-CiC environments.
*/
public function publish( string $key, $value=array(), bool $encrypted=FALSE ) :int
{
return $this->connection('write')->publish( $key, $this->encode($value, $encrypted) );
}
/**
* Increments the score of a member from a sorted set by a given amount.
* Overloaded here so it can be logged and a ttl set
*
* @param string $key Key
* @param int $inc Value to increment
* @param string $value Value
* @param int|NULL $ttl TTL in seconds
* @return boolean
*/
public function zIncrBy( $key, $inc, $value, $ttl=NULL )
{
$return = $this->connection('write')->zIncrBy( $this->key( $key ), $inc, $value );
$this->connection('write')->expire( $this->key( $key ), ( $ttl ? $ttl : static::$ttl ) );
if ( \IPS\REDIS_LOG )
{
static::$log[ sprintf( '%.4f', microtime(true) ) ] = array( 'redis', "(write) zIncrBy " . $key . " = " . $return, json_encode( $value ) );
}
return $return;
}
/**
* Strip prefixes from keys as PHP redis will handle this
*
* @param string $key Key
* @return string
*/
protected function key( $key )
{
if ( $this->prefix )
{
if ( mb_substr( $key, 0, mb_strlen( $this->prefix ) ) == $this->prefix )
{
return str_replace( $this->prefix, '', $key );
}
}
return $key;
}
/**
* Encode
*
* @param mixed $value Value
* @return string
*/
public function encode( $value, $encryptIfCic=TRUE )
{
if ( \IPS\CIC && $encryptIfCic )
{
return \IPS\Text\Encrypt::fromPlaintext( json_encode( $value ) )->tag();
}
else
{
return json_encode( $value );
}
}
/**
* Decode
*
* @param mixed $value Value
* @return mixed
*/
public function decode( $value )
{
if ( \IPS\CIC )
{
$decoded = json_decode( \IPS\Text\Encrypt::fromTag( $value )->decrypt(), TRUE );
if( $decoded === NULL )
{
throw new \RedisException;
}
}
else
{
$decoded = json_decode( $value, TRUE );
}
return $decoded;
}
/**
* Reset connection
*
* @param \RedisException|NULL $e If this was called as a result of an exception, log that to the debug log
* @return void
*/
public function resetConnection( \RedisException $e = NULL )
{
if ( $e !== NULL )
{
\IPS\Log::debug( $e, 'redis_exception' );
}
static::$multitons = array();
if ( \IPS\REDIS_LOG )
{
static::$log[ microtime() ] = array( 'redis', "Redis connections reset" );
}
}
/**
* Is Redis working?
*
* @return bool
*/
public function test()
{
return ( \count( static::$connections ) ) ? TRUE : FALSE;
}
/**
* Debug method to fetch all keys.
*
* @warning Never use this in production, as it can expose sensitive data!
* @param string $pattern Pattern (* to fetch all)
* @param boolean $keyNamesOnly Return names only
* @return array
* @throws \RedisException
*/
public function debugGetKeys( $pattern='*', $keyNamesOnly=FALSE )
{
$this->connection('write')->setOption( \Redis::OPT_SCAN, \Redis::SCAN_RETRY );
$return = array();
$iterator = NULL;
while( $keys = $this->connection('write')->scan( $iterator, $this->prefix . $this->key( $pattern ) ) )
{
if ( $keyNamesOnly)
{
$return = array_merge( $return, $keys );
}
else
{
foreach( $keys as $key )
{
$key = $this->key( $key );
$type = $this->connection('write')->type( $key );
$ttl = \IPS\Redis::i()->ttl( $key );
switch( $type )
{
case \Redis::REDIS_STRING:
if ( mb_stristr( $key, '_pg__page_' ) )
{
$return[ $key . ' (TTL: ' . $ttl . ')' ] = @gzdecode( \IPS\Text\Encrypt::fromCipher( \IPS\Redis::i()->get( $key ) )->decrypt() );
}
else
{
$return[ $key . ' (TTL: ' . $ttl . ')' ] = \IPS\Redis::i()->decode( $this->connection('write')->get( $key ) );
}
break;
case \Redis::REDIS_ZSET:
$return[ $key . ' (TTL: ' . $ttl . ')' ] = $this->connection('write')->zRange( $key, 0, -1, TRUE );
break;
case \Redis::REDIS_HASH:
$return[ $key . ' (TTL: ' . $ttl . ')' ] = $this->connection('write')->hGetAll( $key );
if ( isset( $return[ $key . ' (TTL: ' . $ttl . ')' ]['data'] ) )
{
$return[ $key . ' (TTL: ' . $ttl . ')' ]['data'] = \IPS\Redis::i()->decode( $return[ $key . ' (TTL: ' . $ttl . ')' ]['data'] );
}
break;
case \Redis::REDIS_LIST:
$return[ $key . ' (TTL: ' . $ttl . ')' ] = $this->connection('write')->lRange( $key, 0, -1 );
break;
}
}
}
}
return $return;
}
}