* @copyright (c) 2025, Robert Strutts * @license MIT */ namespace IOcornerstone\Framework\Services; use IOcornerstone\Framework\GzCompression; use IOcornerstone\Framework\Enum\CompressionMethod as Method; class RedisSessionHandler implements SessionHandlerInterface { private $redis; private $prefix; private $ttl; private $lockTimeout = 10; private $lockRetries = 5; private $lockWait = 100000; // microseconds private $enc; public function __construct(callable $enc, array $config = []) { $defaults = [ 'host' => '127.0.0.1', 'port' => 6379, 'prefix' => 'PHPREDIS_SESSION:', 'ttl' => null, // null means use session.gc_maxlifetime 'auth' => null, 'persistent' => true, 'database' => 1, 'timeout' => 0, 'read_timeout' => 2, 'retry_interval' => 100 ]; $config = array_merge($defaults, $config); $this->prefix = $config['prefix']; // Use configured TTL if provided, otherwise use session.gc_maxlifetime $this->ttl = $config['ttl'] ?? (int)ini_get('session.gc_maxlifetime'); // Fallback to 1440 seconds if neither is set if ($this->ttl <= 0) { $this->ttl = 1440; } $this->redis = new Redis(); if ($config['persistent']) { $this->redis->pconnect($config['host'], $config['port'], $config['timeout'], $config['persistent']); } else { $this->redis->connect($config['host'], $config['port'], $config['timeout']); } if ($config['auth']) { $this->redis->auth($config['auth']); } if ($config['database']) { $this->redis->select($config['database']); } if ($config['read_timeout']) { $this->redis->setOption(Redis::OPT_READ_TIMEOUT, $config['read_timeout']); } if ($config['retry_interval']) { $this->redis->setOption(Redis::OPT_RETRY_INTERVAL, $config['retry_interval']); } $method = $config['method'] ?: Method::DEFLATE; $level = $config['level'] ?: 4; $enabled = $config['enabled'] ?: true; $this->compression = new GzCompression($method, $level, $enabled); $this->enc = $enc; } private function writeHelper($data): string { $gc = $this->compression->compress($data); if ($gc !== false) { $data = $gc; // data is now compressed } if ($this->enc === false) { return $data; // encryption is off } $e = $this->enc->encrypt($data); return ($e !== false) ? $e : $data; // and encrypted } private function readHelper($data): string { if ($data === null || $data === false) { return ""; } if ($this->enc !== false) { $de = $this->enc->decrypt($data); if ($de!== false) { $data = $de; // data is now decrypted } } $gd = $this->compression->decompress($data); return ($gd !== false) ? $gd : $data; } public function open($savePath, $sessionName): bool { return true; } public function close(): bool { return true; } public function read($sessionId): string { $key = $this->prefix . $sessionId; $attempts = 0; // Simple locking mechanism to prevent race conditions while ($attempts < $this->lockRetries) { $lock = $this->redis->setnx($key . '.lock', 1); if ($lock || $attempts === $this->lockRetries - 1) { if ($lock) { $this->redis->expire($key . '.lock', $this->lockTimeout); } $data = $this->redis->get($key); $this->redis->del($key . '.lock'); return $this->readHelper($data) ?: ''; } usleep($this->lockWait); $attempts++; } return ''; } public function write($sessionId, $data): bool { $data = write_helper($data); $key = $this->prefix . $sessionId; // Only write if the session has changed if (isset($_SESSION['__LAST_ACTIVE__'])) { $oldData = $this->redis->get($key); if ($oldData === $data) { // Just update TTL return $this->redis->expire($key, $this->ttl); } } $_SESSION['__LAST_ACTIVE__'] = time(); return $this->redis->setex($key, $this->ttl, $data); } public function destroy($sessionId): bool { $key = $this->prefix . $sessionId; $this->redis->del($key . '.lock'); return (bool)$this->redis->del($key); } public function gc($maxLifetime): bool { // Redis handles expiration automatically return true; } }