Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.00% covered (success)
80.00%
32 / 40
33.33% covered (danger)
33.33%
1 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
RedisSessionHandler
80.00% covered (success)
80.00%
32 / 40
33.33% covered (danger)
33.33%
1 / 3
16.80
0.00% covered (danger)
0.00%
0 / 1
 configure
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 validateConnection
68.42% covered (warning)
68.42%
13 / 19
0.00% covered (danger)
0.00%
0 / 1
4.50
 buildSocketTarget
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2
3/**
4 * Configures native PHP Redis-backed sessions with connectivity checks.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-14
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Session;
21
22use RuntimeException;
23
24class RedisSessionHandler
25{
26    public const string DEFAULT_DSN = 'tcp://redis:6379?database=0';
27
28    /* @mago-expect lint:no-ini-set - registering the Redis session handler requires runtime session settings */
29    public static function configure(string $dsn = '', bool $validate = false): void
30    {
31        if (!extension_loaded('redis')) {
32            throw new RuntimeException('Redis session handler requires the PHP redis extension (ext-redis).');
33        }
34
35        $redisDsn = trim($dsn) !== '' ? trim($dsn) : self::DEFAULT_DSN;
36
37        if ($validate) {
38            self::validateConnection($redisDsn);
39        }
40
41        ini_set('session.save_handler', value: 'redis');
42        ini_set('session.save_path', value: $redisDsn);
43    }
44
45    private const float MAX_TIMEOUT_SECONDS = 3.0;
46
47    public static function validateConnection(string $dsn, float $timeoutSeconds = 1.0): void
48    {
49        $timeoutSeconds = min(max($timeoutSeconds, 0.1), self::MAX_TIMEOUT_SECONDS);
50
51        [$socketTarget, $displayTarget] = self::buildSocketTarget($dsn);
52
53        $errno = 0;
54        $errorString = '';
55        /* @mago-expect lint:no-error-control-operator - connection failure is reported via $errno/$errorString below */
56        $connection = @stream_socket_client(
57            $socketTarget,
58            $errno,
59            $errorString,
60            $timeoutSeconds,
61            STREAM_CLIENT_CONNECT,
62        );
63
64        if ($connection === false) {
65            throw new RuntimeException(sprintf('Redis connection failed for %s.', $displayTarget));
66        }
67
68        // Verify the service speaks Redis by sending PING and checking for +PONG
69        fwrite($connection, data: "PING\r\n");
70        stream_set_timeout($connection, (int) ceil($timeoutSeconds));
71        $response = fgets($connection, length: 128);
72        fclose($connection);
73
74        if ($response === false || !str_starts_with(trim($response), '+PONG')) {
75            throw new RuntimeException(sprintf('Redis connection failed for %s.', $displayTarget));
76        }
77    }
78
79    /**
80     * @return array{0: string, 1: string}
81     */
82    private static function buildSocketTarget(string $dsn): array
83    {
84        $parsedUrl = parse_url($dsn);
85        if (!is_array($parsedUrl) || !array_key_exists('scheme', $parsedUrl)) {
86            throw new RuntimeException('Invalid Redis DSN for sessions.');
87        }
88
89        $scheme = strtolower($parsedUrl['scheme']);
90        if ($scheme === 'redis' || $scheme === 'tcp') {
91            $host = $parsedUrl['host'] ?? '127.0.0.1';
92            $port = (int) ($parsedUrl['port'] ?? 6379);
93            return [sprintf('tcp://%s:%d', $host, $port), sprintf('%s:%d', $host, $port)];
94        }
95
96        if ($scheme === 'unix') {
97            $path = $parsedUrl['path'] ?? '';
98            if ($path === '') {
99                throw new RuntimeException('Invalid Redis unix socket DSN for sessions.');
100            }
101
102            return ['unix://' . $path, $path];
103        }
104
105        throw new RuntimeException(sprintf('Unsupported Redis DSN scheme "%s" for sessions.', $scheme));
106    }
107}