620 lines
16 KiB
PHP
620 lines
16 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 */
|
|
|
|
use BadMethodCallException;
|
|
use IPS\Session\Store;
|
|
use IPS\Text\Encrypt;
|
|
use Redis as PHPRedis;
|
|
use RedisException;
|
|
use function array_slice;
|
|
use function count;
|
|
use function defined;
|
|
use function stristr;
|
|
|
|
if ( !defined( '\IPS\SUITE_UNIQUE_KEY' ) )
|
|
{
|
|
header( ( $_SERVER['SERVER_PROTOCOL'] ?? 'HTTP/1.0' ) . ' 403 Forbidden' );
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Redis Cache Class
|
|
*
|
|
* @mixin PHPRedis
|
|
*/
|
|
class Redis
|
|
{
|
|
/**
|
|
* @brief Multiton Store
|
|
*/
|
|
protected static array $multitons = array();
|
|
|
|
/**
|
|
* @brief Connections Store
|
|
* @var \Redis[]
|
|
*/
|
|
protected static array $connections = array();
|
|
|
|
/**
|
|
* @brief Default expiration for keys in seconds
|
|
*/
|
|
protected static int $ttl = 604800; #7 days
|
|
|
|
/**
|
|
* @brief Log what redis is up to
|
|
*/
|
|
public static array $log = array();
|
|
|
|
/**
|
|
* @brief Prefix
|
|
*/
|
|
public ?string $prefix = NULL;
|
|
|
|
/**
|
|
* @brief Unpack the config once
|
|
*/
|
|
protected static ?array $config = NULL;
|
|
|
|
/**
|
|
* Writes made
|
|
*/
|
|
protected static array $writes = array();
|
|
|
|
/**
|
|
* @var array
|
|
*/
|
|
protected static array $connectionAttempts = [];
|
|
|
|
/**
|
|
* Get instance
|
|
*
|
|
* @param array|null $configuration Configuration to use (NULL to use \IPS\REDIS_CONFIG or \IPS\CACHE_CONFIG)
|
|
* @param string|null $identifier Identifier (to support multiple instances)
|
|
* @return Redis
|
|
*/
|
|
public static function i( array $configuration=NULL, string $identifier=NULL ) : Redis
|
|
{
|
|
if ( static::$config === NULL )
|
|
{
|
|
$config = ( defined( '\IPS\REDIS_CONFIG' ) and REDIS_CONFIG !== NULL ) ? REDIS_CONFIG : CACHE_CONFIG;
|
|
static::$config = $configuration ?: json_decode( $config, true );
|
|
}
|
|
|
|
$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 = SUITE_UNIQUE_KEY . '_';
|
|
}
|
|
|
|
/* Return */
|
|
return static::$multitons[ $identifier ];
|
|
}
|
|
|
|
/**
|
|
* Destructor
|
|
*
|
|
* @return void
|
|
*/
|
|
public function __destruct()
|
|
{
|
|
if ( DEBUG_LOG and count( static::$writes ) > 50 )
|
|
{
|
|
$slice = implode( ', ', array_slice( static::$writes, 0, 50 ) );
|
|
Log::debug( "Large number of Redis writes: " . $slice, 'redis_writes' );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @brief Chosen reader for this session
|
|
*/
|
|
protected static ?int $reader = NULL;
|
|
|
|
/**
|
|
* Connect to Redis
|
|
*
|
|
* @param string|null $identifier Identifier
|
|
* @return PHPRedis
|
|
* @throws RedisException If connection cannot be established
|
|
* @throws BadMethodCallException If Redis PHP extension is not installed
|
|
*/
|
|
public function connection( string $identifier=NULL ): PHPRedis
|
|
{
|
|
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 PHPRedis;
|
|
|
|
/* Track connection requests */
|
|
if ( ! isset( static::$connectionAttempts[ $identifier ] ) )
|
|
{
|
|
static::$connectionAttempts[ $identifier ] = 0;
|
|
}
|
|
|
|
/* Prevent too many connection attempts per request */
|
|
if( static::$connectionAttempts[ $identifier ] >= \IPS\REDIS_MAX_CONNECTION_ATTEMPTS )
|
|
{
|
|
throw new \RedisException('CANNOT_CONNECT_TOO_MANY_ATTEMPTS');
|
|
}
|
|
|
|
static::$connectionAttempts[ $identifier ]++;
|
|
|
|
/* 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'], 1, '', 500 ) === FALSE ) # Connect with a 1 second timeout, and 500ms between connection attempts
|
|
{
|
|
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( PHPRedis::OPT_SERIALIZER, PHPRedis::SERIALIZER_NONE );
|
|
static::$connections[ $identifier ]->setOption( PHPRedis::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 (REDIS_LOG)
|
|
{
|
|
static::$log[ sprintf( '%.4f', microtime(true) ) ] = array( 'redis', "Redis connected (" . $identifier . ' ' . $useConfig['server'] . ")" );
|
|
}
|
|
|
|
if ( count( static::$connections ) === 1 ) # Only set the shutdown function if this is the first connection so we don't get multiple shutdown functions
|
|
{
|
|
register_shutdown_function( function( $object ){
|
|
try
|
|
{
|
|
/* First we have to make sure sessions have written */
|
|
if( Store::i() instanceof 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( string $method, mixed $args )
|
|
{
|
|
if ( method_exists( 'Redis', $method ) )
|
|
{
|
|
$type = ( stristr( $method, 'get' ) or stristr( $method, 'RevRange' ) or $method === 'lRange' ) ? 'read' : 'write';
|
|
$return = $this->connection( $type )->$method( ...$args );
|
|
|
|
if ( $type === 'write' and count( $args ) )
|
|
{
|
|
static::$writes[] = $args[0];
|
|
}
|
|
|
|
if ( 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;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 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 int 1 if the element is added. 0 otherwise.
|
|
*/
|
|
public function zAdd( string $key, float $score, string $value, int $ttl=NULL ): int
|
|
{
|
|
$return = $this->connection('write')->zAdd( $this->key( $key ), $score, $value );
|
|
|
|
$this->connection('write')->expire( $this->key( $key ), ( $ttl ?: static::$ttl ) );
|
|
|
|
if (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( string $key, array $value, int $ttl=NULL ): bool
|
|
{
|
|
$return = $this->connection('write')->hMSet( $this->key( $key ), $value );
|
|
|
|
$this->connection('write')->expire( $this->key( $key ), ( $ttl ?: static::$ttl ) );
|
|
|
|
if (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( string $key, ?int $ttl, string $value ): bool
|
|
{
|
|
$return = $this->connection('write')->setEx( $this->key( $key ), $ttl, $value );
|
|
|
|
if (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|int
|
|
*/
|
|
public function sort( string $key, array $options=array() ): array|int
|
|
{
|
|
$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 (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( string $key ): array
|
|
{
|
|
/* Make sure we read */
|
|
$return = $this->connection('read')->hGetAll( $this->key( $key ) );
|
|
|
|
if (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, mixed $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( string $key, int $inc, string $value, int $ttl=NULL ): bool
|
|
{
|
|
$return = $this->connection('write')->zIncrBy( $this->key( $key ), $inc, $value );
|
|
$this->connection('write')->expire( $this->key( $key ), ( $ttl ?: static::$ttl ) );
|
|
|
|
if (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( string $key ): string
|
|
{
|
|
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
|
|
* @param bool $encryptIfCic Encrypt for CiC
|
|
* @return string
|
|
*/
|
|
public function encode(mixed $value, bool $encryptIfCic=TRUE ): string
|
|
{
|
|
if ( CIC && $encryptIfCic )
|
|
{
|
|
return Encrypt::fromPlaintext( json_encode( $value ) )->tag();
|
|
}
|
|
else
|
|
{
|
|
return json_encode( $value );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode
|
|
*
|
|
* @param mixed $value Value
|
|
* @return mixed
|
|
*/
|
|
public function decode( mixed $value ): mixed
|
|
{
|
|
if (CIC)
|
|
{
|
|
$decoded = json_decode( Encrypt::fromTag( $value )->decrypt(), TRUE );
|
|
|
|
if( $decoded === NULL )
|
|
{
|
|
throw new RedisException('DECODE_ERROR');
|
|
}
|
|
}
|
|
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 ) : void
|
|
{
|
|
$message = '';
|
|
|
|
if ( $e !== NULL )
|
|
{
|
|
$message = $e->getMessage();
|
|
Log::debug( $e, 'redis_exception' );
|
|
}
|
|
|
|
static::$multitons = array();
|
|
|
|
if (REDIS_LOG)
|
|
{
|
|
static::$log[ microtime() ] = array( 'redis', "Redis connections reset " . $message );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Is Redis working?
|
|
*
|
|
* @return bool
|
|
*/
|
|
public function test(): bool
|
|
{
|
|
return (bool) count( static::$connections );
|
|
}
|
|
|
|
/**
|
|
* 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( string $pattern='*', bool $keyNamesOnly=FALSE ): array
|
|
{
|
|
$this->connection('write')->setOption( PHPRedis::OPT_SCAN, PHPRedis::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 = $this->ttl( $key );
|
|
|
|
switch( $type )
|
|
{
|
|
case PHPRedis::REDIS_STRING:
|
|
if ( mb_stristr( $key, '_pg__page_' ) )
|
|
{
|
|
$return[ $key . ' (TTL: ' . $ttl . ')' ] = @gzdecode( Encrypt::fromCipher( $this->get( $key ) )->decrypt() );
|
|
}
|
|
else
|
|
{
|
|
$return[ $key . ' (TTL: ' . $ttl . ')' ] = Redis::i()->decode( $this->connection('write')->get( $key ) );
|
|
}
|
|
break;
|
|
case PHPRedis::REDIS_ZSET:
|
|
$return[ $key . ' (TTL: ' . $ttl . ')' ] = $this->connection('write')->zRange( $key, 0, -1, TRUE );
|
|
break;
|
|
case PHPRedis::REDIS_HASH:
|
|
$return[ $key . ' (TTL: ' . $ttl . ')' ] = $this->connection('write')->hGetAll( $key );
|
|
if ( isset( $return[ $key . ' (TTL: ' . $ttl . ')' ]['data'] ) )
|
|
{
|
|
$return[ $key . ' (TTL: ' . $ttl . ')' ]['data'] = Redis::i()->decode( $return[ $key . ' (TTL: ' . $ttl . ')' ]['data'] );
|
|
}
|
|
break;
|
|
case PHPRedis::REDIS_LIST:
|
|
$return[ $key . ' (TTL: ' . $ttl . ')' ] = $this->connection('write')->lRange( $key, 0, -1 );
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return $return;
|
|
}
|
|
|
|
/**
|
|
* A quick and consistent way to see if Redis can be used
|
|
*
|
|
* @return bool
|
|
*/
|
|
static public function isEnabled(): bool
|
|
{
|
|
return class_exists('Redis') and REDIS_ENABLED and REDIS_CONFIG;
|
|
}
|
|
} |