| 1 | efrain | 1 | <?php
 | 
        
           |  |  | 2 | // This file is part of Moodle - http://moodle.org/
 | 
        
           |  |  | 3 | //
 | 
        
           |  |  | 4 | // Moodle is free software: you can redistribute it and/or modify
 | 
        
           |  |  | 5 | // it under the terms of the GNU General Public License as published by
 | 
        
           |  |  | 6 | // the Free Software Foundation, either version 3 of the License, or
 | 
        
           |  |  | 7 | // (at your option) any later version.
 | 
        
           |  |  | 8 | //
 | 
        
           |  |  | 9 | // Moodle is distributed in the hope that it will be useful,
 | 
        
           |  |  | 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
        
           |  |  | 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
        
           |  |  | 12 | // GNU General Public License for more details.
 | 
        
           |  |  | 13 | //
 | 
        
           |  |  | 14 | // You should have received a copy of the GNU General Public License
 | 
        
           |  |  | 15 | // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 | 
        
           |  |  | 16 |   | 
        
           |  |  | 17 | namespace core\session;
 | 
        
           |  |  | 18 |   | 
        
           | 1441 | ariadna | 19 | use coding_exception;
 | 
        
           |  |  | 20 | use core\di;
 | 
        
           |  |  | 21 | use core\clock;
 | 
        
           |  |  | 22 | use RedisCluster;
 | 
        
           |  |  | 23 | use RedisClusterException;
 | 
        
           | 1 | efrain | 24 | use RedisException;
 | 
        
           |  |  | 25 | use SessionHandlerInterface;
 | 
        
           |  |  | 26 |   | 
        
           |  |  | 27 | /**
 | 
        
           |  |  | 28 |  * Redis based session handler.
 | 
        
           |  |  | 29 |  *
 | 
        
           |  |  | 30 |  * @package    core
 | 
        
           | 1441 | ariadna | 31 |  * @copyright  Russell Smith
 | 
        
           | 1 | efrain | 32 |  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 | 
        
           |  |  | 33 |  */
 | 
        
           |  |  | 34 | class redis extends handler implements SessionHandlerInterface {
 | 
        
           |  |  | 35 |     /**
 | 
        
           |  |  | 36 |      * Compressor: none.
 | 
        
           |  |  | 37 |      */
 | 
        
           |  |  | 38 |     const COMPRESSION_NONE      = 'none';
 | 
        
           |  |  | 39 |     /**
 | 
        
           |  |  | 40 |      * Compressor: PHP GZip.
 | 
        
           |  |  | 41 |      */
 | 
        
           |  |  | 42 |     const COMPRESSION_GZIP      = 'gzip';
 | 
        
           |  |  | 43 |     /**
 | 
        
           |  |  | 44 |      * Compressor: PHP Zstandard.
 | 
        
           |  |  | 45 |      */
 | 
        
           |  |  | 46 |     const COMPRESSION_ZSTD      = 'zstd';
 | 
        
           |  |  | 47 |   | 
        
           | 1441 | ariadna | 48 |     /** @var string Minimum server version */
 | 
        
           |  |  | 49 |     const REDIS_MIN_SERVER_VERSION = "5.0.0";
 | 
        
           |  |  | 50 |   | 
        
           |  |  | 51 |     /** @var string Minimum extension version */
 | 
        
           |  |  | 52 |     const REDIS_MIN_EXTENSION_VERSION = "5.1.0";
 | 
        
           |  |  | 53 |   | 
        
           | 1 | efrain | 54 |     /** @var array $host save_path string  */
 | 
        
           |  |  | 55 |     protected array $host = [];
 | 
        
           |  |  | 56 |     /** @var int $port The port to connect to */
 | 
        
           |  |  | 57 |     protected $port = 6379;
 | 
        
           |  |  | 58 |     /** @var array $sslopts SSL options, if applicable */
 | 
        
           |  |  | 59 |     protected $sslopts = [];
 | 
        
           |  |  | 60 |     /** @var string $auth redis password  */
 | 
        
           |  |  | 61 |     protected $auth = '';
 | 
        
           |  |  | 62 |     /** @var int $database the Redis database to store sesions in */
 | 
        
           |  |  | 63 |     protected $database = 0;
 | 
        
           |  |  | 64 |     /** @var array $servers list of servers parsed from save_path */
 | 
        
           |  |  | 65 |     protected $prefix = '';
 | 
        
           | 1441 | ariadna | 66 |   | 
        
           |  |  | 67 |     /** @var string $sessionkeyprefix the prefix for the session key */
 | 
        
           |  |  | 68 |     protected string $sessionkeyprefix = 'session_';
 | 
        
           |  |  | 69 |   | 
        
           |  |  | 70 |     /** @var string $userkeyprefix the prefix for the user key */
 | 
        
           |  |  | 71 |     protected string $userkeyprefix = 'user_';
 | 
        
           |  |  | 72 |   | 
        
           | 1 | efrain | 73 |     /** @var int $acquiretimeout how long to wait for session lock in seconds */
 | 
        
           |  |  | 74 |     protected $acquiretimeout = 120;
 | 
        
           |  |  | 75 |     /** @var int $acquirewarn how long before warning when waiting for a lock in seconds */
 | 
        
           |  |  | 76 |     protected $acquirewarn = null;
 | 
        
           |  |  | 77 |     /** @var int $lockretry how long to wait between session lock attempts in ms */
 | 
        
           |  |  | 78 |     protected $lockretry = 100;
 | 
        
           |  |  | 79 |     /** @var int $serializer The serializer to use */
 | 
        
           |  |  | 80 |     protected $serializer = \Redis::SERIALIZER_PHP;
 | 
        
           |  |  | 81 |     /** @var int $compressor The compressor to use */
 | 
        
           |  |  | 82 |     protected $compressor = self::COMPRESSION_NONE;
 | 
        
           |  |  | 83 |     /** @var string $lasthash hash of the session data content */
 | 
        
           |  |  | 84 |     protected $lasthash = null;
 | 
        
           |  |  | 85 |   | 
        
           | 1441 | ariadna | 86 |     /** @var int $gcbatchsize The number of redis keys that will be processed each time the garbage collector is executed. */
 | 
        
           |  |  | 87 |     protected int $gcbatchsize = 100;
 | 
        
           |  |  | 88 |   | 
        
           | 1 | efrain | 89 |     /**
 | 
        
           | 1441 | ariadna | 90 |      * How long to wait in seconds before expiring the lock automatically so that other requests may continue execution.
 | 
        
           |  |  | 91 |      *
 | 
        
           |  |  | 92 |      * @var int $lockexpire
 | 
        
           | 1 | efrain | 93 |      */
 | 
        
           | 1441 | ariadna | 94 |     protected int $lockexpire;
 | 
        
           | 1 | efrain | 95 |   | 
        
           | 1441 | ariadna | 96 |     /** @var \Redis|\RedisCluster|null Connection */
 | 
        
           |  |  | 97 |     protected \Redis|\RedisCluster|null $connection = null;
 | 
        
           | 1 | efrain | 98 |   | 
        
           |  |  | 99 |     /** @var array $locks List of currently held locks by this page. */
 | 
        
           |  |  | 100 |     protected $locks = array();
 | 
        
           |  |  | 101 |   | 
        
           |  |  | 102 |     /** @var int $timeout How long sessions live before expiring. */
 | 
        
           |  |  | 103 |     protected $timeout;
 | 
        
           |  |  | 104 |   | 
        
           |  |  | 105 |     /** @var bool $clustermode Redis in cluster mode. */
 | 
        
           |  |  | 106 |     protected bool $clustermode = false;
 | 
        
           |  |  | 107 |   | 
        
           |  |  | 108 |     /** @var int Maximum number of retries for cache store operations. */
 | 
        
           | 1441 | ariadna | 109 |     protected int $maxretries = 3;
 | 
        
           | 1 | efrain | 110 |   | 
        
           | 1441 | ariadna | 111 |     /** @var int $firstaccesstimeout The initial timeout (seconds) for the first browser access without login. */
 | 
        
           |  |  | 112 |     protected int $firstaccesstimeout = 180;
 | 
        
           |  |  | 113 |   | 
        
           |  |  | 114 |     /** @var clock A clock instance */
 | 
        
           |  |  | 115 |     protected clock $clock;
 | 
        
           |  |  | 116 |   | 
        
           |  |  | 117 |     /** @var int $connectiontimeout The number of seconds to wait for a connection or response from the Redis server. */
 | 
        
           |  |  | 118 |     protected int $connectiontimeout = 3;
 | 
        
           |  |  | 119 |   | 
        
           | 1 | efrain | 120 |     /**
 | 
        
           |  |  | 121 |      * Create new instance of handler.
 | 
        
           |  |  | 122 |      */
 | 
        
           |  |  | 123 |     public function __construct() {
 | 
        
           |  |  | 124 |         global $CFG;
 | 
        
           |  |  | 125 |   | 
        
           |  |  | 126 |         if (isset($CFG->session_redis_host)) {
 | 
        
           |  |  | 127 |             // If there is only one host, use the single Redis connection.
 | 
        
           |  |  | 128 |             // If there are multiple hosts (separated by a comma), use the Redis cluster connection.
 | 
        
           |  |  | 129 |             $this->host = array_filter(array_map('trim', explode(',', $CFG->session_redis_host)));
 | 
        
           |  |  | 130 |             $this->clustermode = count($this->host) > 1;
 | 
        
           |  |  | 131 |         }
 | 
        
           |  |  | 132 |   | 
        
           |  |  | 133 |         if (isset($CFG->session_redis_port)) {
 | 
        
           |  |  | 134 |             $this->port = (int)$CFG->session_redis_port;
 | 
        
           |  |  | 135 |         }
 | 
        
           |  |  | 136 |   | 
        
           |  |  | 137 |         if (isset($CFG->session_redis_encrypt) && $CFG->session_redis_encrypt) {
 | 
        
           |  |  | 138 |             $this->sslopts = $CFG->session_redis_encrypt;
 | 
        
           |  |  | 139 |         }
 | 
        
           |  |  | 140 |   | 
        
           |  |  | 141 |         if (isset($CFG->session_redis_auth)) {
 | 
        
           |  |  | 142 |             $this->auth = $CFG->session_redis_auth;
 | 
        
           |  |  | 143 |         }
 | 
        
           |  |  | 144 |   | 
        
           |  |  | 145 |         if (isset($CFG->session_redis_database)) {
 | 
        
           |  |  | 146 |             $this->database = (int)$CFG->session_redis_database;
 | 
        
           |  |  | 147 |         }
 | 
        
           |  |  | 148 |   | 
        
           |  |  | 149 |         if (isset($CFG->session_redis_prefix)) {
 | 
        
           |  |  | 150 |             $this->prefix = $CFG->session_redis_prefix;
 | 
        
           |  |  | 151 |         }
 | 
        
           |  |  | 152 |   | 
        
           |  |  | 153 |         if (isset($CFG->session_redis_acquire_lock_timeout)) {
 | 
        
           |  |  | 154 |             $this->acquiretimeout = (int)$CFG->session_redis_acquire_lock_timeout;
 | 
        
           |  |  | 155 |         }
 | 
        
           |  |  | 156 |   | 
        
           |  |  | 157 |         if (isset($CFG->session_redis_acquire_lock_warn)) {
 | 
        
           |  |  | 158 |             $this->acquirewarn = (int)$CFG->session_redis_acquire_lock_warn;
 | 
        
           |  |  | 159 |         }
 | 
        
           |  |  | 160 |   | 
        
           |  |  | 161 |         if (isset($CFG->session_redis_acquire_lock_retry)) {
 | 
        
           |  |  | 162 |             $this->lockretry = (int)$CFG->session_redis_acquire_lock_retry;
 | 
        
           |  |  | 163 |         }
 | 
        
           |  |  | 164 |   | 
        
           |  |  | 165 |         if (!empty($CFG->session_redis_serializer_use_igbinary) && defined('\Redis::SERIALIZER_IGBINARY')) {
 | 
        
           |  |  | 166 |             $this->serializer = \Redis::SERIALIZER_IGBINARY; // Set igbinary serializer if phpredis supports it.
 | 
        
           |  |  | 167 |         }
 | 
        
           |  |  | 168 |   | 
        
           |  |  | 169 |         // This sets the Redis session lock expiry time to whatever is lower, either
 | 
        
           | 1441 | ariadna | 170 |         // the PHP execution time `max_execution_time`, if the value is positive, or the
 | 
        
           |  |  | 171 |         // globally configured `sessiontimeout`.
 | 
        
           |  |  | 172 |         //
 | 
        
           |  |  | 173 |         // Setting it to the lower of the two will not make things worse it if the execution timeout
 | 
        
           | 1 | efrain | 174 |         // is longer than the session timeout.
 | 
        
           | 1441 | ariadna | 175 |         //
 | 
        
           | 1 | efrain | 176 |         // For the PHP execution time, once the PHP execution time is over, we can be sure
 | 
        
           |  |  | 177 |         // that the lock is no longer actively held so that the lock can expire safely.
 | 
        
           | 1441 | ariadna | 178 |         //
 | 
        
           | 1 | efrain | 179 |         // Although at `lib/classes/php_time_limit.php::raise(int)`, Moodle can
 | 
        
           |  |  | 180 |         // progressively increase the maximum PHP execution time, this is limited to the
 | 
        
           |  |  | 181 |         // `max_execution_time` value defined in the `php.ini`.
 | 
        
           |  |  | 182 |         // For the session timeout, we assume it is safe to consider the lock to expire
 | 
        
           |  |  | 183 |         // once the session itself expires.
 | 
        
           |  |  | 184 |         // If we unnecessarily hold the lock any longer, it blocks other session requests.
 | 
        
           |  |  | 185 |         $this->lockexpire = ini_get('max_execution_time');
 | 
        
           | 1441 | ariadna | 186 |         if ($this->lockexpire < 0) {
 | 
        
           |  |  | 187 |             // If the max_execution_time is set to a value lower than 0, which is invalid, use the default value.
 | 
        
           |  |  | 188 |             // https://www.php.net/manual/en/info.configuration.php#ini.max-execution-time defines the default as 30.
 | 
        
           |  |  | 189 |             // Note: This value is not available programatically.
 | 
        
           |  |  | 190 |             $this->lockexpire = 30;
 | 
        
           |  |  | 191 |         }
 | 
        
           |  |  | 192 |   | 
        
           | 1 | efrain | 193 |         if (empty($this->lockexpire) || ($this->lockexpire > (int)$CFG->sessiontimeout)) {
 | 
        
           | 1441 | ariadna | 194 |             // The value of the max_execution_time is either unlimited (0), or higher than the session timeout.
 | 
        
           |  |  | 195 |             // Cap it at the session timeout.
 | 
        
           | 1 | efrain | 196 |             $this->lockexpire = (int)$CFG->sessiontimeout;
 | 
        
           |  |  | 197 |         }
 | 
        
           | 1441 | ariadna | 198 |   | 
        
           | 1 | efrain | 199 |         if (isset($CFG->session_redis_lock_expire)) {
 | 
        
           |  |  | 200 |             $this->lockexpire = (int)$CFG->session_redis_lock_expire;
 | 
        
           |  |  | 201 |         }
 | 
        
           |  |  | 202 |   | 
        
           |  |  | 203 |         if (isset($CFG->session_redis_compressor)) {
 | 
        
           |  |  | 204 |             $this->compressor = $CFG->session_redis_compressor;
 | 
        
           |  |  | 205 |         }
 | 
        
           |  |  | 206 |   | 
        
           | 1441 | ariadna | 207 |         if (isset($CFG->session_redis_connection_timeout)) {
 | 
        
           |  |  | 208 |             $this->connectiontimeout = (int)$CFG->session_redis_connection_timeout;
 | 
        
           |  |  | 209 |         }
 | 
        
           | 1 | efrain | 210 |   | 
        
           | 1441 | ariadna | 211 |         if (isset($CFG->session_redis_max_retries)) {
 | 
        
           |  |  | 212 |             $this->maxretries = (int)$CFG->session_redis_max_retries;
 | 
        
           |  |  | 213 |         }
 | 
        
           |  |  | 214 |   | 
        
           |  |  | 215 |         $this->clock = di::get(clock::class);
 | 
        
           | 1 | efrain | 216 |     }
 | 
        
           |  |  | 217 |   | 
        
           | 1441 | ariadna | 218 |     #[\Override]
 | 
        
           |  |  | 219 |     public function init(): bool {
 | 
        
           | 1 | efrain | 220 |         if (!extension_loaded('redis')) {
 | 
        
           |  |  | 221 |             throw new exception('sessionhandlerproblem', 'error', '', null, 'redis extension is not loaded');
 | 
        
           |  |  | 222 |         }
 | 
        
           |  |  | 223 |   | 
        
           |  |  | 224 |         if (empty($this->host)) {
 | 
        
           | 1441 | ariadna | 225 |             throw new exception(
 | 
        
           |  |  | 226 |                 'sessionhandlerproblem',
 | 
        
           |  |  | 227 |                 'error',
 | 
        
           |  |  | 228 |                 '',
 | 
        
           |  |  | 229 |                 null,
 | 
        
           |  |  | 230 |                 '$CFG->session_redis_host must be specified in config.php',
 | 
        
           |  |  | 231 |             );
 | 
        
           | 1 | efrain | 232 |         }
 | 
        
           |  |  | 233 |   | 
        
           |  |  | 234 |         $version = phpversion('Redis');
 | 
        
           | 1441 | ariadna | 235 |         if (!$version || version_compare($version, self::REDIS_MIN_EXTENSION_VERSION) <= 0) {
 | 
        
           |  |  | 236 |             throw new exception(
 | 
        
           |  |  | 237 |                 errorcode: 'sessionhandlerproblem',
 | 
        
           |  |  | 238 |                 module: 'error',
 | 
        
           |  |  | 239 |                 debuginfo: sprintf('redis extension version must be at least %s', self::REDIS_MIN_EXTENSION_VERSION),
 | 
        
           |  |  | 240 |             );
 | 
        
           | 1 | efrain | 241 |         }
 | 
        
           |  |  | 242 |   | 
        
           |  |  | 243 |         $result = session_set_save_handler($this);
 | 
        
           |  |  | 244 |         if (!$result) {
 | 
        
           |  |  | 245 |             throw new exception('redissessionhandlerproblem', 'error');
 | 
        
           |  |  | 246 |         }
 | 
        
           |  |  | 247 |   | 
        
           |  |  | 248 |         $encrypt = (bool) ($this->sslopts ?? false);
 | 
        
           |  |  | 249 |         // Set Redis server(s).
 | 
        
           |  |  | 250 |         $trimmedservers = [];
 | 
        
           |  |  | 251 |         foreach ($this->host as $host) {
 | 
        
           |  |  | 252 |             $server = strtolower(trim($host));
 | 
        
           |  |  | 253 |             if (!empty($server)) {
 | 
        
           |  |  | 254 |                 if ($server[0] === '/' || str_starts_with($server, 'unix://')) {
 | 
        
           |  |  | 255 |                     $port = 0;
 | 
        
           |  |  | 256 |                     $trimmedservers[] = $server;
 | 
        
           |  |  | 257 |                 } else {
 | 
        
           |  |  | 258 |                     $port = $this->port ?? 6379; // No Unix socket so set default port.
 | 
        
           |  |  | 259 |                     if (strpos($server, ':')) { // Check for custom port.
 | 
        
           |  |  | 260 |                         list($server, $port) = explode(':', $server);
 | 
        
           |  |  | 261 |                     }
 | 
        
           |  |  | 262 |                     $trimmedservers[] = $server.':'.$port;
 | 
        
           |  |  | 263 |                 }
 | 
        
           |  |  | 264 |   | 
        
           |  |  | 265 |                 // We only need the first record for the single redis.
 | 
        
           |  |  | 266 |                 if (!$this->clustermode) {
 | 
        
           |  |  | 267 |                     // Handle the case when the server is not a Unix domain socket.
 | 
        
           |  |  | 268 |                     if ($port !== 0) {
 | 
        
           |  |  | 269 |                         list($server, ) = explode(':', $trimmedservers[0]);
 | 
        
           |  |  | 270 |                     } else {
 | 
        
           |  |  | 271 |                         $server = $trimmedservers[0];
 | 
        
           |  |  | 272 |                     }
 | 
        
           |  |  | 273 |                     break;
 | 
        
           |  |  | 274 |                 }
 | 
        
           |  |  | 275 |             }
 | 
        
           |  |  | 276 |         }
 | 
        
           |  |  | 277 |   | 
        
           |  |  | 278 |         // TLS/SSL Configuration.
 | 
        
           |  |  | 279 |         $opts = [];
 | 
        
           |  |  | 280 |         if ($encrypt) {
 | 
        
           |  |  | 281 |             if ($this->clustermode) {
 | 
        
           |  |  | 282 |                 $opts = $this->sslopts;
 | 
        
           |  |  | 283 |             } else {
 | 
        
           |  |  | 284 |                 // For a single (non-cluster) Redis, the TLS/SSL config must be added to the 'stream' key.
 | 
        
           |  |  | 285 |                 $opts['stream'] = $this->sslopts;
 | 
        
           |  |  | 286 |             }
 | 
        
           |  |  | 287 |         }
 | 
        
           |  |  | 288 |   | 
        
           | 1441 | ariadna | 289 |         // Add retries for connections to make sure it goes through.
 | 
        
           | 1 | efrain | 290 |         $counter = 1;
 | 
        
           |  |  | 291 |         $exceptionclass = $this->clustermode ? 'RedisClusterException' : 'RedisException';
 | 
        
           | 1441 | ariadna | 292 |         while ($counter <= $this->maxretries) {
 | 
        
           | 1 | efrain | 293 |             $this->connection = null;
 | 
        
           |  |  | 294 |             // Make a connection to Redis server(s).
 | 
        
           |  |  | 295 |             try {
 | 
        
           |  |  | 296 |                 // Create a $redis object of a RedisCluster or Redis class.
 | 
        
           | 1441 | ariadna | 297 |                 $phpredisversion = phpversion('redis');
 | 
        
           | 1 | efrain | 298 |                 if ($this->clustermode) {
 | 
        
           | 1441 | ariadna | 299 |                     if (version_compare($phpredisversion, '6.0.0', '>=')) {
 | 
        
           |  |  | 300 |                         // Named parameters are fully supported starting from version 6.0.0.
 | 
        
           |  |  | 301 |                         $this->connection = new \RedisCluster(
 | 
        
           |  |  | 302 |                             name: null,
 | 
        
           |  |  | 303 |                             seeds: $trimmedservers,
 | 
        
           |  |  | 304 |                             timeout: $this->connectiontimeout, // Timeout.
 | 
        
           |  |  | 305 |                             read_timeout: $this->connectiontimeout, // Read timeout.
 | 
        
           |  |  | 306 |                             persistent: true,
 | 
        
           |  |  | 307 |                             auth: $this->auth,
 | 
        
           |  |  | 308 |                             context: !empty($opts) ? $opts : null,
 | 
        
           |  |  | 309 |                         );
 | 
        
           |  |  | 310 |                     } else {
 | 
        
           |  |  | 311 |                         $this->connection = new \RedisCluster(
 | 
        
           |  |  | 312 |                             null,
 | 
        
           |  |  | 313 |                             $trimmedservers,
 | 
        
           |  |  | 314 |                             $this->connectiontimeout,
 | 
        
           |  |  | 315 |                             $this->connectiontimeout,
 | 
        
           |  |  | 316 |                             true,
 | 
        
           |  |  | 317 |                             $this->auth,
 | 
        
           |  |  | 318 |                             !empty($opts) ? $opts : null
 | 
        
           |  |  | 319 |                         );
 | 
        
           |  |  | 320 |                     }
 | 
        
           | 1 | efrain | 321 |                 } else {
 | 
        
           |  |  | 322 |                     $delay = rand(100, 500);
 | 
        
           |  |  | 323 |                     $this->connection = new \Redis();
 | 
        
           | 1441 | ariadna | 324 |                     if (version_compare($phpredisversion, '6.0.0', '>=')) {
 | 
        
           |  |  | 325 |                         // Named parameters are fully supported starting from version 6.0.0.
 | 
        
           |  |  | 326 |                         $this->connection->connect(
 | 
        
           |  |  | 327 |                             host: $server,
 | 
        
           |  |  | 328 |                             port: $port,
 | 
        
           |  |  | 329 |                             timeout: $this->connectiontimeout, // Timeout.
 | 
        
           |  |  | 330 |                             retry_interval: $delay,
 | 
        
           |  |  | 331 |                             read_timeout: $this->connectiontimeout, // Read timeout.
 | 
        
           |  |  | 332 |                             context: $opts,
 | 
        
           |  |  | 333 |                         );
 | 
        
           |  |  | 334 |                     } else {
 | 
        
           |  |  | 335 |                         $this->connection->connect(
 | 
        
           |  |  | 336 |                             $server,
 | 
        
           |  |  | 337 |                             $port,
 | 
        
           |  |  | 338 |                             $this->connectiontimeout,
 | 
        
           |  |  | 339 |                             null,
 | 
        
           |  |  | 340 |                             $delay,
 | 
        
           |  |  | 341 |                             $this->connectiontimeout,
 | 
        
           |  |  | 342 |                             $opts
 | 
        
           |  |  | 343 |                         );
 | 
        
           |  |  | 344 |                     }
 | 
        
           |  |  | 345 |   | 
        
           | 1 | efrain | 346 |                     if ($this->auth !== '' && !$this->connection->auth($this->auth)) {
 | 
        
           |  |  | 347 |                         throw new $exceptionclass('Unable to authenticate.');
 | 
        
           |  |  | 348 |                     }
 | 
        
           |  |  | 349 |                 }
 | 
        
           |  |  | 350 |   | 
        
           |  |  | 351 |                 if (!$this->connection->setOption(\Redis::OPT_SERIALIZER, $this->serializer)) {
 | 
        
           |  |  | 352 |                     throw new $exceptionclass('Unable to set the Redis PHP Serializer option.');
 | 
        
           |  |  | 353 |                 }
 | 
        
           |  |  | 354 |                 if ($this->prefix !== '') {
 | 
        
           |  |  | 355 |                     // Use custom prefix on sessions.
 | 
        
           |  |  | 356 |                     if (!$this->connection->setOption(\Redis::OPT_PREFIX, $this->prefix)) {
 | 
        
           |  |  | 357 |                         throw new $exceptionclass('Unable to set the Redis Prefix option.');
 | 
        
           |  |  | 358 |                     }
 | 
        
           |  |  | 359 |                 }
 | 
        
           | 1441 | ariadna | 360 |   | 
        
           |  |  | 361 |                 // Check the server version.
 | 
        
           |  |  | 362 |                 // The session handler requires a version of Redis server with support for SET command options (at least 2.6.12).
 | 
        
           |  |  | 363 |                 // Note: In the case of a TLS connection, the connection will hang if the phpredis client does not communicate
 | 
        
           |  |  | 364 |                 // with the server immediately after connect(). See https://github.com/phpredis/phpredis/issues/2332.
 | 
        
           |  |  | 365 |                 // This version check satisfies that requirement.
 | 
        
           |  |  | 366 |                 try {
 | 
        
           |  |  | 367 |                     $serverversion = $this->connection->info('server')['redis_version'];
 | 
        
           |  |  | 368 |                 } catch (RedisException | RedisClusterException $e) {
 | 
        
           |  |  | 369 |                     // Some proxies e.g envoy or twemproxy lack support of INFO command. So just assume we meet the minimum
 | 
        
           |  |  | 370 |                     // version requirement.
 | 
        
           |  |  | 371 |                     $serverversion = self::REDIS_MIN_SERVER_VERSION;
 | 
        
           | 1 | efrain | 372 |                 }
 | 
        
           | 1441 | ariadna | 373 |                 if (version_compare($serverversion, self::REDIS_MIN_SERVER_VERSION) < 0) {
 | 
        
           |  |  | 374 |                     throw new $exceptionclass(sprintf(
 | 
        
           |  |  | 375 |                         "Version %s is not supported. The minimum version required is %s.",
 | 
        
           |  |  | 376 |                         $serverversion,
 | 
        
           |  |  | 377 |                         self::REDIS_MIN_SERVER_VERSION,
 | 
        
           |  |  | 378 |                     ));
 | 
        
           |  |  | 379 |                 }
 | 
        
           |  |  | 380 |   | 
        
           | 1 | efrain | 381 |                 if ($this->database !== 0) {
 | 
        
           |  |  | 382 |                     if (!$this->connection->select($this->database)) {
 | 
        
           |  |  | 383 |                         throw new $exceptionclass('Unable to select the Redis database ' . $this->database . '.');
 | 
        
           |  |  | 384 |                     }
 | 
        
           |  |  | 385 |                 }
 | 
        
           | 1441 | ariadna | 386 |   | 
        
           | 1 | efrain | 387 |                 return true;
 | 
        
           |  |  | 388 |             } catch (RedisException | RedisClusterException $e) {
 | 
        
           | 1441 | ariadna | 389 |                 $redishost = $this->clustermode ? implode(',', $this->host) : $server . ':' . $port;
 | 
        
           |  |  | 390 |                 $logstring = "Failed to connect (try {$counter} out of " . $this->maxretries . ") to Redis ";
 | 
        
           | 1 | efrain | 391 |                 $logstring .= "at ". $redishost .", the error returned was: {$e->getMessage()}";
 | 
        
           |  |  | 392 |                 debugging($logstring);
 | 
        
           |  |  | 393 |             }
 | 
        
           |  |  | 394 |             $counter++;
 | 
        
           |  |  | 395 |             // Introduce a random sleep between 100ms and 500ms.
 | 
        
           |  |  | 396 |             usleep(rand(100000, 500000));
 | 
        
           |  |  | 397 |         }
 | 
        
           |  |  | 398 |   | 
        
           |  |  | 399 |         if (isset($logstring)) {
 | 
        
           |  |  | 400 |             // We have exhausted our retries; it's time to give up.
 | 
        
           |  |  | 401 |             throw new $exceptionclass($logstring);
 | 
        
           |  |  | 402 |         }
 | 
        
           |  |  | 403 |   | 
        
           |  |  | 404 |         $result = session_set_save_handler($this);
 | 
        
           |  |  | 405 |         if (!$result) {
 | 
        
           |  |  | 406 |             throw new exception('redissessionhandlerproblem', 'error');
 | 
        
           |  |  | 407 |         }
 | 
        
           | 1441 | ariadna | 408 |         return false;
 | 
        
           | 1 | efrain | 409 |     }
 | 
        
           |  |  | 410 |   | 
        
           |  |  | 411 |     /**
 | 
        
           |  |  | 412 |      * Update our session search path to include session name when opened.
 | 
        
           |  |  | 413 |      *
 | 
        
           |  |  | 414 |      * @param string $path  unused session save path. (ignored)
 | 
        
           |  |  | 415 |      * @param string $name Session name for this session. (ignored)
 | 
        
           |  |  | 416 |      * @return bool true always as we will succeed.
 | 
        
           |  |  | 417 |      */
 | 
        
           |  |  | 418 |     public function open(string $path, string $name): bool {
 | 
        
           |  |  | 419 |         return true;
 | 
        
           |  |  | 420 |     }
 | 
        
           |  |  | 421 |   | 
        
           |  |  | 422 |     /**
 | 
        
           |  |  | 423 |      * Close the session completely. We also remove all locks we may have obtained that aren't expired.
 | 
        
           |  |  | 424 |      *
 | 
        
           |  |  | 425 |      * @return bool true on success.  false on unable to unlock sessions.
 | 
        
           |  |  | 426 |      */
 | 
        
           |  |  | 427 |     public function close(): bool {
 | 
        
           |  |  | 428 |         $this->lasthash = null;
 | 
        
           |  |  | 429 |         try {
 | 
        
           |  |  | 430 |             foreach ($this->locks as $id => $expirytime) {
 | 
        
           | 1441 | ariadna | 431 |                 if ($expirytime > $this->clock->time()) {
 | 
        
           | 1 | efrain | 432 |                     $this->unlock_session($id);
 | 
        
           |  |  | 433 |                 }
 | 
        
           |  |  | 434 |                 unset($this->locks[$id]);
 | 
        
           |  |  | 435 |             }
 | 
        
           |  |  | 436 |         } catch (RedisException | RedisClusterException $e) {
 | 
        
           |  |  | 437 |             error_log('Failed talking to redis: '.$e->getMessage());
 | 
        
           |  |  | 438 |             return false;
 | 
        
           |  |  | 439 |         }
 | 
        
           |  |  | 440 |   | 
        
           |  |  | 441 |         return true;
 | 
        
           |  |  | 442 |     }
 | 
        
           |  |  | 443 |   | 
        
           |  |  | 444 |     /**
 | 
        
           |  |  | 445 |      * Read the session data from storage
 | 
        
           |  |  | 446 |      *
 | 
        
           |  |  | 447 |      * @param string $id The session id to read from storage.
 | 
        
           | 1441 | ariadna | 448 |      * @return string|false The session data for PHP to process or false.
 | 
        
           | 1 | efrain | 449 |      *
 | 
        
           |  |  | 450 |      * @throws RedisException when we are unable to talk to the Redis server.
 | 
        
           |  |  | 451 |      */
 | 
        
           |  |  | 452 |     public function read(string $id): string|false {
 | 
        
           |  |  | 453 |         try {
 | 
        
           |  |  | 454 |             if ($this->requires_write_lock()) {
 | 
        
           | 1441 | ariadna | 455 |                 $this->lock_session($this->sessionkeyprefix . $id);
 | 
        
           | 1 | efrain | 456 |             }
 | 
        
           |  |  | 457 |   | 
        
           | 1441 | ariadna | 458 |             $keys = $this->connection->hmget($this->sessionkeyprefix . $id, ['userid', 'sessdata']);
 | 
        
           |  |  | 459 |             $userid = $keys['userid'];
 | 
        
           |  |  | 460 |             $sessiondata = $this->uncompress($keys['sessdata']);
 | 
        
           |  |  | 461 |   | 
        
           | 1 | efrain | 462 |             if ($sessiondata === false) {
 | 
        
           |  |  | 463 |                 if ($this->requires_write_lock()) {
 | 
        
           | 1441 | ariadna | 464 |                     $this->unlock_session($this->sessionkeyprefix . $id);
 | 
        
           | 1 | efrain | 465 |                 }
 | 
        
           |  |  | 466 |                 $this->lasthash = sha1('');
 | 
        
           |  |  | 467 |                 return '';
 | 
        
           |  |  | 468 |             }
 | 
        
           | 1441 | ariadna | 469 |   | 
        
           |  |  | 470 |             // Do not update expiry if non-login user (0). This would affect the first access timeout.
 | 
        
           |  |  | 471 |             if ($userid != 0) {
 | 
        
           |  |  | 472 |                 $maxlifetime = $this->get_maxlifetime($userid);
 | 
        
           |  |  | 473 |                 $this->connection->expire($this->sessionkeyprefix . $id, $maxlifetime);
 | 
        
           |  |  | 474 |                 $this->connection->expire($this->userkeyprefix . $userid, $maxlifetime);
 | 
        
           |  |  | 475 |             }
 | 
        
           | 1 | efrain | 476 |         } catch (RedisException | RedisClusterException $e) {
 | 
        
           |  |  | 477 |             error_log('Failed talking to redis: '.$e->getMessage());
 | 
        
           |  |  | 478 |             throw $e;
 | 
        
           |  |  | 479 |         }
 | 
        
           | 1441 | ariadna | 480 |   | 
        
           |  |  | 481 |         // Update last hash.
 | 
        
           |  |  | 482 |         if ($sessiondata === null) {
 | 
        
           |  |  | 483 |             // As of PHP 8.1 we can't pass null to base64_encode.
 | 
        
           |  |  | 484 |             $sessiondata = '';
 | 
        
           |  |  | 485 |         }
 | 
        
           |  |  | 486 |   | 
        
           | 1 | efrain | 487 |         $this->lasthash = sha1(base64_encode($sessiondata));
 | 
        
           |  |  | 488 |         return $sessiondata;
 | 
        
           |  |  | 489 |     }
 | 
        
           |  |  | 490 |   | 
        
           |  |  | 491 |     /**
 | 
        
           |  |  | 492 |      * Compresses session data.
 | 
        
           |  |  | 493 |      *
 | 
        
           |  |  | 494 |      * @param mixed $value
 | 
        
           |  |  | 495 |      * @return string
 | 
        
           |  |  | 496 |      */
 | 
        
           | 1441 | ariadna | 497 |     private function compress($value): string {
 | 
        
           | 1 | efrain | 498 |         switch ($this->compressor) {
 | 
        
           |  |  | 499 |             case self::COMPRESSION_NONE:
 | 
        
           |  |  | 500 |                 return $value;
 | 
        
           |  |  | 501 |             case self::COMPRESSION_GZIP:
 | 
        
           |  |  | 502 |                 return gzencode($value);
 | 
        
           |  |  | 503 |             case self::COMPRESSION_ZSTD:
 | 
        
           |  |  | 504 |                 return zstd_compress($value);
 | 
        
           |  |  | 505 |             default:
 | 
        
           |  |  | 506 |                 debugging("Invalid compressor: {$this->compressor}");
 | 
        
           |  |  | 507 |                 return $value;
 | 
        
           |  |  | 508 |         }
 | 
        
           |  |  | 509 |     }
 | 
        
           |  |  | 510 |   | 
        
           |  |  | 511 |     /**
 | 
        
           |  |  | 512 |      * Uncompresses session data.
 | 
        
           |  |  | 513 |      *
 | 
        
           |  |  | 514 |      * @param string $value
 | 
        
           |  |  | 515 |      * @return mixed
 | 
        
           |  |  | 516 |      */
 | 
        
           |  |  | 517 |     private function uncompress($value) {
 | 
        
           |  |  | 518 |         if ($value === false) {
 | 
        
           |  |  | 519 |             return false;
 | 
        
           |  |  | 520 |         }
 | 
        
           |  |  | 521 |   | 
        
           |  |  | 522 |         switch ($this->compressor) {
 | 
        
           |  |  | 523 |             case self::COMPRESSION_NONE:
 | 
        
           |  |  | 524 |                 break;
 | 
        
           |  |  | 525 |             case self::COMPRESSION_GZIP:
 | 
        
           |  |  | 526 |                 $value = gzdecode($value);
 | 
        
           |  |  | 527 |                 break;
 | 
        
           |  |  | 528 |             case self::COMPRESSION_ZSTD:
 | 
        
           |  |  | 529 |                 $value = zstd_uncompress($value);
 | 
        
           |  |  | 530 |                 break;
 | 
        
           |  |  | 531 |             default:
 | 
        
           |  |  | 532 |                 debugging("Invalid compressor: {$this->compressor}");
 | 
        
           |  |  | 533 |         }
 | 
        
           |  |  | 534 |   | 
        
           |  |  | 535 |         return $value;
 | 
        
           |  |  | 536 |     }
 | 
        
           |  |  | 537 |   | 
        
           |  |  | 538 |     /**
 | 
        
           |  |  | 539 |      * Write the serialized session data to our session store.
 | 
        
           |  |  | 540 |      *
 | 
        
           |  |  | 541 |      * @param string $id session id to write.
 | 
        
           |  |  | 542 |      * @param string $data session data
 | 
        
           |  |  | 543 |      * @return bool true on write success, false on failure
 | 
        
           |  |  | 544 |      */
 | 
        
           |  |  | 545 |     public function write(string $id, string $data): bool {
 | 
        
           |  |  | 546 |         $hash = sha1(base64_encode($data));
 | 
        
           |  |  | 547 |   | 
        
           |  |  | 548 |         // If the content has not changed don't bother writing.
 | 
        
           |  |  | 549 |         if ($hash === $this->lasthash) {
 | 
        
           |  |  | 550 |             return true;
 | 
        
           |  |  | 551 |         }
 | 
        
           |  |  | 552 |   | 
        
           |  |  | 553 |         if (is_null($this->connection)) {
 | 
        
           |  |  | 554 |             // The session has already been closed, don't attempt another write.
 | 
        
           |  |  | 555 |             error_log('Tried to write session: '.$id.' before open or after close.');
 | 
        
           |  |  | 556 |             return false;
 | 
        
           |  |  | 557 |         }
 | 
        
           |  |  | 558 |   | 
        
           |  |  | 559 |         // We do not do locking here because memcached doesn't.  Also
 | 
        
           |  |  | 560 |         // PHP does open, read, destroy, write, close. When a session doesn't exist.
 | 
        
           |  |  | 561 |         // There can be race conditions on new sessions racing each other but we can
 | 
        
           |  |  | 562 |         // address that in the future.
 | 
        
           |  |  | 563 |         try {
 | 
        
           |  |  | 564 |             $data = $this->compress($data);
 | 
        
           | 1441 | ariadna | 565 |             $this->connection->hset($this->sessionkeyprefix . $id, 'sessdata', $data);
 | 
        
           |  |  | 566 |             $keys = $this->connection->hmget($this->sessionkeyprefix . $id, ['userid', 'timecreated', 'timemodified']);
 | 
        
           |  |  | 567 |             $userid = $keys['userid'];
 | 
        
           | 1 | efrain | 568 |   | 
        
           | 1441 | ariadna | 569 |             // Don't update expiry if still first access.
 | 
        
           |  |  | 570 |             if ($keys['timecreated'] != $keys['timemodified']) {
 | 
        
           |  |  | 571 |                 $maxlifetime = $this->get_maxlifetime($userid);
 | 
        
           |  |  | 572 |                 $this->connection->expire($this->sessionkeyprefix . $id, $maxlifetime);
 | 
        
           |  |  | 573 |                 $this->connection->expire($this->userkeyprefix . $userid, $maxlifetime);
 | 
        
           |  |  | 574 |             }
 | 
        
           | 1 | efrain | 575 |         } catch (RedisException | RedisClusterException $e) {
 | 
        
           |  |  | 576 |             error_log('Failed talking to redis: '.$e->getMessage());
 | 
        
           |  |  | 577 |             return false;
 | 
        
           |  |  | 578 |         }
 | 
        
           |  |  | 579 |         return true;
 | 
        
           |  |  | 580 |     }
 | 
        
           |  |  | 581 |   | 
        
           | 1441 | ariadna | 582 |     #[\Override]
 | 
        
           |  |  | 583 |     public function get_session_by_sid(string $sid): \stdClass {
 | 
        
           |  |  | 584 |         $this->init_redis_if_required();
 | 
        
           |  |  | 585 |         $keys = ["id", "state", "sid", "userid", "sessdata", "timecreated", "timemodified", "firstip", "lastip"];
 | 
        
           |  |  | 586 |         $sessiondata = $this->connection->hmget($this->sessionkeyprefix . $sid, $keys);
 | 
        
           |  |  | 587 |   | 
        
           |  |  | 588 |         return (object)$sessiondata;
 | 
        
           |  |  | 589 |     }
 | 
        
           |  |  | 590 |   | 
        
           |  |  | 591 |     #[\Override]
 | 
        
           |  |  | 592 |     public function add_session(int $userid): \stdClass {
 | 
        
           |  |  | 593 |         $timestamp = $this->clock->time();
 | 
        
           |  |  | 594 |         $sid = session_id();
 | 
        
           |  |  | 595 |         $maxlifetime = $this->get_maxlifetime($userid, true);
 | 
        
           |  |  | 596 |         $sessiondata = [
 | 
        
           |  |  | 597 |             'id' => $sid,
 | 
        
           |  |  | 598 |             'state' => '0',
 | 
        
           |  |  | 599 |             'sid' => $sid,
 | 
        
           |  |  | 600 |             'userid' => $userid,
 | 
        
           |  |  | 601 |             'sessdata' => null,
 | 
        
           |  |  | 602 |             'timecreated' => $timestamp,
 | 
        
           |  |  | 603 |             'timemodified' => $timestamp,
 | 
        
           |  |  | 604 |             'firstip' => getremoteaddr(),
 | 
        
           |  |  | 605 |             'lastip' => getremoteaddr(),
 | 
        
           |  |  | 606 |         ];
 | 
        
           |  |  | 607 |   | 
        
           |  |  | 608 |         $userhashkey = $this->userkeyprefix . $userid;
 | 
        
           |  |  | 609 |         $this->connection->hSet($userhashkey, $sid, $timestamp);
 | 
        
           |  |  | 610 |         $this->connection->expire($userhashkey, $maxlifetime);
 | 
        
           |  |  | 611 |   | 
        
           |  |  | 612 |         $sessionhashkey = $this->sessionkeyprefix . $sid;
 | 
        
           |  |  | 613 |         $this->connection->hmSet($sessionhashkey, $sessiondata);
 | 
        
           |  |  | 614 |         $this->connection->expire($sessionhashkey, $maxlifetime);
 | 
        
           |  |  | 615 |   | 
        
           |  |  | 616 |         return (object)$sessiondata;
 | 
        
           |  |  | 617 |     }
 | 
        
           |  |  | 618 |   | 
        
           |  |  | 619 |     #[\Override]
 | 
        
           |  |  | 620 |     public function get_sessions_by_userid(int $userid): array {
 | 
        
           |  |  | 621 |         $this->init_redis_if_required();
 | 
        
           |  |  | 622 |   | 
        
           |  |  | 623 |         $userhashkey = $this->userkeyprefix . $userid;
 | 
        
           |  |  | 624 |         $sessions = $this->connection->hGetAll($userhashkey);
 | 
        
           |  |  | 625 |         $records = [];
 | 
        
           |  |  | 626 |         foreach (array_keys($sessions) as $session) {
 | 
        
           |  |  | 627 |             $item = $this->connection->hGetAll($this->sessionkeyprefix . $session);
 | 
        
           |  |  | 628 |             if (!empty($item)) {
 | 
        
           |  |  | 629 |                 $records[] = (object) $item;
 | 
        
           |  |  | 630 |             }
 | 
        
           |  |  | 631 |         }
 | 
        
           |  |  | 632 |         return $records;
 | 
        
           |  |  | 633 |     }
 | 
        
           |  |  | 634 |   | 
        
           |  |  | 635 |     #[\Override]
 | 
        
           |  |  | 636 |     public function update_session(\stdClass $record): bool {
 | 
        
           |  |  | 637 |         if (!isset($record->sid) && isset($record->id)) {
 | 
        
           |  |  | 638 |             $record->sid = $record->id;
 | 
        
           |  |  | 639 |         }
 | 
        
           |  |  | 640 |   | 
        
           |  |  | 641 |         // If record does not have userid set, we need to get it from the session.
 | 
        
           |  |  | 642 |         if (!isset($record->userid)) {
 | 
        
           |  |  | 643 |             $session = $this->get_session_by_sid($record->sid);
 | 
        
           |  |  | 644 |             $record->userid = $session->userid;
 | 
        
           |  |  | 645 |         }
 | 
        
           |  |  | 646 |   | 
        
           |  |  | 647 |         $sessionhashkey = $this->sessionkeyprefix . $record->sid;
 | 
        
           |  |  | 648 |         $userhashkey = $this->userkeyprefix . $record->userid;
 | 
        
           |  |  | 649 |   | 
        
           |  |  | 650 |         $recordata = (array) $record;
 | 
        
           |  |  | 651 |         unset($recordata['sid']);
 | 
        
           |  |  | 652 |         $this->connection->hmSet($sessionhashkey, $recordata);
 | 
        
           |  |  | 653 |   | 
        
           |  |  | 654 |         // Update the expiry time.
 | 
        
           |  |  | 655 |         $maxlifetime = $this->get_maxlifetime($record->userid);
 | 
        
           |  |  | 656 |         $this->connection->expire($sessionhashkey, $maxlifetime);
 | 
        
           |  |  | 657 |         $this->connection->expire($userhashkey, $maxlifetime);
 | 
        
           |  |  | 658 |   | 
        
           |  |  | 659 |         return true;
 | 
        
           |  |  | 660 |     }
 | 
        
           |  |  | 661 |   | 
        
           |  |  | 662 |   | 
        
           |  |  | 663 |     #[\Override]
 | 
        
           |  |  | 664 |     public function get_all_sessions(): \Iterator {
 | 
        
           |  |  | 665 |         $sessions = [];
 | 
        
           |  |  | 666 |         $iterator = null;
 | 
        
           |  |  | 667 |         while (false !== ($keys = $this->connection->scan($iterator, '*' . $this->sessionkeyprefix . '*'))) {
 | 
        
           |  |  | 668 |             foreach ($keys as $key) {
 | 
        
           |  |  | 669 |                 $sessions[] = $key;
 | 
        
           |  |  | 670 |             }
 | 
        
           |  |  | 671 |         }
 | 
        
           |  |  | 672 |         return new \ArrayIterator($sessions);
 | 
        
           |  |  | 673 |     }
 | 
        
           |  |  | 674 |   | 
        
           |  |  | 675 |     #[\Override]
 | 
        
           |  |  | 676 |     public function destroy_all(): bool {
 | 
        
           |  |  | 677 |         $this->init_redis_if_required();
 | 
        
           |  |  | 678 |   | 
        
           |  |  | 679 |         $sessions = $this->get_all_sessions();
 | 
        
           |  |  | 680 |         foreach ($sessions as $session) {
 | 
        
           |  |  | 681 |             // Remove the prefixes from the session id, as destroy expects the raw session id.
 | 
        
           |  |  | 682 |             if (str_starts_with($session, $this->prefix . $this->sessionkeyprefix)) {
 | 
        
           |  |  | 683 |                 $session = substr($session, strlen($this->prefix . $this->sessionkeyprefix));
 | 
        
           |  |  | 684 |             }
 | 
        
           |  |  | 685 |   | 
        
           |  |  | 686 |             $this->destroy($session);
 | 
        
           |  |  | 687 |         }
 | 
        
           |  |  | 688 |         return true;
 | 
        
           |  |  | 689 |     }
 | 
        
           |  |  | 690 |   | 
        
           |  |  | 691 |     #[\Override]
 | 
        
           | 1 | efrain | 692 |     public function destroy(string $id): bool {
 | 
        
           | 1441 | ariadna | 693 |         $this->init_redis_if_required();
 | 
        
           | 1 | efrain | 694 |         $this->lasthash = null;
 | 
        
           |  |  | 695 |         try {
 | 
        
           | 1441 | ariadna | 696 |             $sessionhashkey = $this->sessionkeyprefix . $id;
 | 
        
           |  |  | 697 |             $userid = $this->connection->hget($sessionhashkey, "userid");
 | 
        
           |  |  | 698 |             $userhashkey = $this->userkeyprefix . $userid;
 | 
        
           |  |  | 699 |             $this->connection->hDel($userhashkey, $id);
 | 
        
           |  |  | 700 |             $this->connection->unlink($sessionhashkey);
 | 
        
           | 1 | efrain | 701 |             $this->unlock_session($id);
 | 
        
           |  |  | 702 |         } catch (RedisException | RedisClusterException $e) {
 | 
        
           |  |  | 703 |             error_log('Failed talking to redis: '.$e->getMessage());
 | 
        
           |  |  | 704 |             return false;
 | 
        
           |  |  | 705 |         }
 | 
        
           |  |  | 706 |   | 
        
           |  |  | 707 |         return true;
 | 
        
           |  |  | 708 |     }
 | 
        
           |  |  | 709 |   | 
        
           | 1441 | ariadna | 710 |     // phpcs:disable moodle.NamingConventions.ValidVariableName.VariableNameUnderscore
 | 
        
           |  |  | 711 |     #[\Override]
 | 
        
           |  |  | 712 |     public function gc(int $max_lifetime = 0): int|false {
 | 
        
           |  |  | 713 |         return 0;
 | 
        
           |  |  | 714 |     }
 | 
        
           |  |  | 715 |     // phpcs:enable
 | 
        
           |  |  | 716 |   | 
        
           | 1 | efrain | 717 |     /**
 | 
        
           | 1441 | ariadna | 718 |      * Get session maximum lifetime in seconds.
 | 
        
           | 1 | efrain | 719 |      *
 | 
        
           | 1441 | ariadna | 720 |      * @param int|null $userid The user id to calculate the max lifetime for.
 | 
        
           |  |  | 721 |      * @param bool $firstbrowseraccess This indicates that this is calculating the expiry when the key is first added.
 | 
        
           |  |  | 722 |      *                                 The first access made by the browser has a shorter timeout to reduce abandoned sessions.
 | 
        
           |  |  | 723 |      * @return float|int
 | 
        
           | 1 | efrain | 724 |      */
 | 
        
           | 1441 | ariadna | 725 |     private function get_maxlifetime(?int $userid = null, bool $firstbrowseraccess = false): float|int {
 | 
        
           |  |  | 726 |         global $CFG;
 | 
        
           |  |  | 727 |   | 
        
           |  |  | 728 |         // Guest user.
 | 
        
           |  |  | 729 |         if ($userid == $CFG->siteguest) {
 | 
        
           |  |  | 730 |             return $CFG->sessiontimeout * 5;
 | 
        
           |  |  | 731 |         }
 | 
        
           |  |  | 732 |   | 
        
           |  |  | 733 |         // All other users.
 | 
        
           |  |  | 734 |         if ($userid == 0 && $firstbrowseraccess) {
 | 
        
           |  |  | 735 |             $maxlifetime = $this->firstaccesstimeout;
 | 
        
           |  |  | 736 |         } else {
 | 
        
           |  |  | 737 |             // As per MDL-56823 - The following configures the session lifetime in redis to allow some
 | 
        
           |  |  | 738 |             // wriggle room in the user noticing they've been booted off and
 | 
        
           |  |  | 739 |             // letting them log back in before they lose their session entirely.
 | 
        
           |  |  | 740 |             $updatefreq = empty($CFG->session_update_timemodified_frequency) ? 20 : $CFG->session_update_timemodified_frequency;
 | 
        
           |  |  | 741 |             $maxlifetime = (int) $CFG->sessiontimeout + $updatefreq + MINSECS;
 | 
        
           |  |  | 742 |         }
 | 
        
           |  |  | 743 |   | 
        
           |  |  | 744 |         return $maxlifetime;
 | 
        
           | 1 | efrain | 745 |     }
 | 
        
           |  |  | 746 |   | 
        
           |  |  | 747 |     /**
 | 
        
           | 1441 | ariadna | 748 |      * Connection will be null if these methods are called from cli or where NO_MOODLE_COOKIES is used.
 | 
        
           |  |  | 749 |      * We need to check for this and initialize the connection if required.
 | 
        
           |  |  | 750 |      *
 | 
        
           |  |  | 751 |      * @return void
 | 
        
           |  |  | 752 |      */
 | 
        
           |  |  | 753 |     private function init_redis_if_required(): void {
 | 
        
           |  |  | 754 |         if (is_null($this->connection)) {
 | 
        
           |  |  | 755 |             $this->init();
 | 
        
           |  |  | 756 |         }
 | 
        
           |  |  | 757 |     }
 | 
        
           |  |  | 758 |   | 
        
           |  |  | 759 |     /**
 | 
        
           | 1 | efrain | 760 |      * Unlock a session.
 | 
        
           |  |  | 761 |      *
 | 
        
           |  |  | 762 |      * @param string $id Session id to be unlocked.
 | 
        
           |  |  | 763 |      */
 | 
        
           |  |  | 764 |     protected function unlock_session($id) {
 | 
        
           |  |  | 765 |         if (isset($this->locks[$id])) {
 | 
        
           | 1441 | ariadna | 766 |             $this->connection->unlink("{$id}.lock");
 | 
        
           | 1 | efrain | 767 |             unset($this->locks[$id]);
 | 
        
           |  |  | 768 |         }
 | 
        
           |  |  | 769 |     }
 | 
        
           |  |  | 770 |   | 
        
           |  |  | 771 |     /**
 | 
        
           |  |  | 772 |      * Obtain a session lock so we are the only one using it at the moment.
 | 
        
           |  |  | 773 |      *
 | 
        
           |  |  | 774 |      * @param string $id The session id to lock.
 | 
        
           |  |  | 775 |      * @return bool true when session was locked, exception otherwise.
 | 
        
           |  |  | 776 |      * @throws exception When we are unable to obtain a session lock.
 | 
        
           |  |  | 777 |      */
 | 
        
           |  |  | 778 |     protected function lock_session($id) {
 | 
        
           | 1441 | ariadna | 779 |         $lockkey = "{$id}.lock";
 | 
        
           | 1 | efrain | 780 |   | 
        
           | 1441 | ariadna | 781 |         $haslock = isset($this->locks[$id]) && $this->clock->time() < $this->locks[$id];
 | 
        
           |  |  | 782 |         if ($haslock) {
 | 
        
           |  |  | 783 |             return true;
 | 
        
           |  |  | 784 |         }
 | 
        
           | 1 | efrain | 785 |   | 
        
           | 1441 | ariadna | 786 |         $startlocktime = $this->clock->time();
 | 
        
           | 1 | efrain | 787 |   | 
        
           | 1441 | ariadna | 788 |         // To be able to ensure sessions don't write out of order we must obtain an exclusive lock
 | 
        
           |  |  | 789 |         // on the session for the entire time it is open.  If another AJAX call, or page is using
 | 
        
           |  |  | 790 |         // the session then we just wait until it finishes before we can open the session.
 | 
        
           |  |  | 791 |   | 
        
           | 1 | efrain | 792 |         // Store the current host, process id and the request URI so it's easy to track who has the lock.
 | 
        
           |  |  | 793 |         $hostname = gethostname();
 | 
        
           |  |  | 794 |         if ($hostname === false) {
 | 
        
           |  |  | 795 |             $hostname = 'UNKNOWN HOST';
 | 
        
           |  |  | 796 |         }
 | 
        
           | 1441 | ariadna | 797 |   | 
        
           | 1 | efrain | 798 |         $pid = getmypid();
 | 
        
           |  |  | 799 |         if ($pid === false) {
 | 
        
           |  |  | 800 |             $pid = 'UNKNOWN';
 | 
        
           |  |  | 801 |         }
 | 
        
           | 1441 | ariadna | 802 |   | 
        
           | 1 | efrain | 803 |         $uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : 'unknown uri';
 | 
        
           |  |  | 804 |   | 
        
           |  |  | 805 |         $whoami = "[pid {$pid}] {$hostname}:$uri";
 | 
        
           |  |  | 806 |   | 
        
           |  |  | 807 |         $haswarned = false; // Have we logged a lock warning?
 | 
        
           |  |  | 808 |   | 
        
           |  |  | 809 |         while (!$haslock) {
 | 
        
           | 1441 | ariadna | 810 |             $haslock = $this->connection->set($lockkey, $whoami, ['nx', 'ex' => $this->lockexpire]);
 | 
        
           | 1 | efrain | 811 |   | 
        
           |  |  | 812 |             if ($haslock) {
 | 
        
           | 1441 | ariadna | 813 |                 $this->locks[$id] = $this->clock->time() + $this->lockexpire;
 | 
        
           | 1 | efrain | 814 |                 return true;
 | 
        
           |  |  | 815 |             }
 | 
        
           |  |  | 816 |   | 
        
           | 1441 | ariadna | 817 |             if (!empty($this->acquirewarn) && !$haswarned && $this->clock->time() > $startlocktime + $this->acquirewarn) {
 | 
        
           | 1 | efrain | 818 |                 // This is a warning to better inform users.
 | 
        
           |  |  | 819 |                 $whohaslock = $this->connection->get($lockkey);
 | 
        
           |  |  | 820 |                 // phpcs:ignore
 | 
        
           | 1441 | ariadna | 821 |                 error_log(
 | 
        
           |  |  | 822 |                     "Warning: Cannot obtain session lock for sid: $id within $this->acquirewarn seconds but will keep trying. " .
 | 
        
           |  |  | 823 |                         "It is likely another page ($whohaslock) has a long session lock, or the session lock was never released.",
 | 
        
           |  |  | 824 |                 );
 | 
        
           | 1 | efrain | 825 |                 $haswarned = true;
 | 
        
           |  |  | 826 |             }
 | 
        
           |  |  | 827 |   | 
        
           | 1441 | ariadna | 828 |             if ($this->clock->time() > $startlocktime + $this->acquiretimeout) {
 | 
        
           | 1 | efrain | 829 |                 // This is a fatal error, better inform users.
 | 
        
           |  |  | 830 |                 // It should not happen very often - all pages that need long time to execute
 | 
        
           |  |  | 831 |                 // should close session immediately after access control checks.
 | 
        
           |  |  | 832 |                 $whohaslock = $this->connection->get($lockkey);
 | 
        
           |  |  | 833 |                 // phpcs:ignore
 | 
        
           | 1441 | ariadna | 834 |                 error_log(
 | 
        
           |  |  | 835 |                     "Error: Cannot obtain session lock for sid: $id within $this->acquiretimeout seconds. " .
 | 
        
           |  |  | 836 |                         "It is likely another page ($whohaslock) has a long session lock, or the session lock was never released.",
 | 
        
           |  |  | 837 |                 );
 | 
        
           | 1 | efrain | 838 |                 $acquiretimeout = format_time($this->acquiretimeout);
 | 
        
           |  |  | 839 |                 $lockexpire = format_time($this->lockexpire);
 | 
        
           |  |  | 840 |                 $a = (object)[
 | 
        
           |  |  | 841 |                     'id' => substr($id, 0, 10),
 | 
        
           |  |  | 842 |                     'acquiretimeout' => $acquiretimeout,
 | 
        
           |  |  | 843 |                     'whohaslock' => $whohaslock,
 | 
        
           | 1441 | ariadna | 844 |                     'lockexpire' => $lockexpire,
 | 
        
           |  |  | 845 |                 ];
 | 
        
           | 1 | efrain | 846 |                 throw new exception("sessioncannotobtainlock", 'error', '', $a);
 | 
        
           |  |  | 847 |             }
 | 
        
           |  |  | 848 |   | 
        
           | 1441 | ariadna | 849 |             if ($this->clock->time() < $startlocktime + 5) {
 | 
        
           | 1 | efrain | 850 |                 // We want a random delay to stagger the polling load. Ideally
 | 
        
           |  |  | 851 |                 // this delay should be a fraction of the average response
 | 
        
           |  |  | 852 |                 // time. If it is too small we will poll too much and if it is
 | 
        
           |  |  | 853 |                 // too large we will waste time waiting for no reason. 100ms is
 | 
        
           |  |  | 854 |                 // the default starting point.
 | 
        
           |  |  | 855 |                 $delay = rand($this->lockretry, (int)($this->lockretry * 1.1));
 | 
        
           |  |  | 856 |             } else {
 | 
        
           |  |  | 857 |                 // If we don't get a lock within 5 seconds then there must be a
 | 
        
           |  |  | 858 |                 // very long lived process holding the lock so throttle back to
 | 
        
           |  |  | 859 |                 // just polling roughly once a second.
 | 
        
           |  |  | 860 |                 $delay = rand(1000, 1100);
 | 
        
           |  |  | 861 |             }
 | 
        
           |  |  | 862 |   | 
        
           |  |  | 863 |             usleep($delay * 1000);
 | 
        
           |  |  | 864 |         }
 | 
        
           | 1441 | ariadna | 865 |         throw new coding_exception('Unable to lock session');
 | 
        
           | 1 | efrain | 866 |     }
 | 
        
           |  |  | 867 |   | 
        
           | 1441 | ariadna | 868 |     #[\Override]
 | 
        
           | 1 | efrain | 869 |     public function session_exists($sid) {
 | 
        
           |  |  | 870 |         if (!$this->connection) {
 | 
        
           |  |  | 871 |             return false;
 | 
        
           |  |  | 872 |         }
 | 
        
           |  |  | 873 |   | 
        
           |  |  | 874 |         try {
 | 
        
           | 1441 | ariadna | 875 |             $sessionhashkey = $this->sessionkeyprefix . $sid;
 | 
        
           |  |  | 876 |             return !empty($this->connection->exists($sessionhashkey));
 | 
        
           | 1 | efrain | 877 |         } catch (RedisException | RedisClusterException $e) {
 | 
        
           |  |  | 878 |             return false;
 | 
        
           |  |  | 879 |         }
 | 
        
           |  |  | 880 |     }
 | 
        
           |  |  | 881 | }
 |