diff --git a/.docker/apache/Dockerfile b/.docker/apache/Dockerfile index 7615f69b8e..df9e99722c 100644 --- a/.docker/apache/Dockerfile +++ b/.docker/apache/Dockerfile @@ -1,12 +1,12 @@ # -# This image uses a php:8.4-apache base image and do not have any phpMyFAQ code with it. +# This image uses a php:8.5-apache base image and do not have any phpMyFAQ code with it. # It's for development only, it's meant to be run with docker-compose # ##################################### #=== Unique stage without payload === ##################################### -FROM php:8.4-apache +FROM php:8.5-apache #=== Install gd PHP dependencie === RUN set -x \ @@ -61,6 +61,10 @@ RUN set -ex \ RUN pecl install xdebug-3.5.0 \ && docker-php-ext-enable xdebug +#=== Install redis PHP extension === +RUN pecl install redis-6.3.0 \ + && docker-php-ext-enable redis + #=== php default === ENV PMF_TIMEZONE="Europe/Berlin" \ PMF_ENABLE_UPLOADS=On \ diff --git a/.docker/frankenphp/Dockerfile b/.docker/frankenphp/Dockerfile index 4600d1661c..7da9e91517 100644 --- a/.docker/frankenphp/Dockerfile +++ b/.docker/frankenphp/Dockerfile @@ -41,6 +41,10 @@ RUN docker-php-ext-configure gd --with-freetype --with-jpeg \ RUN pecl install xdebug \ && docker-php-ext-enable xdebug +#=== Install redis PHP extension === +RUN pecl install redis \ + && docker-php-ext-enable redis + #=== Environment variables === ENV PMF_TIMEZONE="Europe/Berlin" \ PMF_ENABLE_UPLOADS=On \ diff --git a/.docker/php-fpm/Dockerfile b/.docker/php-fpm/Dockerfile index 6eed33c2f4..1a64879962 100644 --- a/.docker/php-fpm/Dockerfile +++ b/.docker/php-fpm/Dockerfile @@ -1,12 +1,12 @@ # -# This image uses a php:8.4-fpm base image and does not have any phpMyFAQ code with it. +# This image uses a php:8.5-fpm base image and does not have any phpMyFAQ code with it. # It's for development only, it's meant to be run with docker-compose # ##################################### #=== Unique stage without payload === ##################################### -FROM php:8.4-fpm +FROM php:8.5-fpm #=== Install gd PHP dependencies === RUN set -x \ @@ -61,6 +61,10 @@ RUN set -ex \ RUN pecl install xdebug-3.5.0 \ && docker-php-ext-enable xdebug +#=== Install redis PHP extension === +RUN pecl install redis \ + && docker-php-ext-enable redis + #=== php default === ENV PMF_TIMEZONE="Europe/Berlin" \ PMF_ENABLE_UPLOADS=On \ diff --git a/README.md b/README.md index 29f9ff3ea8..1167c4b67c 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ and can be run on almost any web hosting provider or deployed in the cloud using ## Requirements -phpMyFAQ is only supported on PHP 8.3 and up, you need a database as well. Supported databases are MySQL, MariaDB, +phpMyFAQ is only supported on PHP 8.4+, you need a database as well. Supported databases are MySQL, MariaDB, Percona Server, PostgreSQL, Microsoft SQL Server, and SQLite3. If you want to use Elasticsearch or Opensearch as the main search engine, you need Elasticsearch v6+ or OpenSearch v1+. Check our detailed requirements on [phpmyfaq.de](https://www.phpmyfaq.de/requirements) for more information. @@ -75,17 +75,18 @@ _Running using named volumes:_ - **sqlserver**: image with Microsoft SQL Server for Linux - **elasticsearch**: Open Source Software image (it means it does not have XPack installed) - **opensearch**: OpenSearch image (it means it does not have XPack installed) +- **redis**: image with a Redis database -_Running apache web server with PHP 8.4 support:_ +_Running apache web server with PHP 8.5 support:_ - **apache**: mounts the `phpmyfaq` folder in place of `/var/www/html`. -_Running nginx web server with PHP 8.4 support:_ +_Running nginx web server with PHP 8.5 support:_ - **nginx**: mounts the `phpmyfaq` folder in place of `/var/www/html`. -- **php-fpm**: PHP-FPM image with PHP 8.4 support +- **php-fpm**: PHP-FPM image with PHP 8.5 support -_Running FrankenPHP web server with PHP 8.4 support:_ +_Running FrankenPHP web server with PHP 8.5 support:_ - **frankenphp**: mounts the `phpmyfaq` folder in place of `/var/www/html`. diff --git a/docker-compose.yml b/docker-compose.yml index 53b5cee1a3..4abeb2c225 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,15 @@ services: volumes: - ./volumes/postgres:/var/lib/postgresql/data + redis: + image: redis:7-alpine + restart: always + command: redis-server --appendonly yes + ports: + - '6379:6379' + volumes: + - ./volumes/redis:/data + #sqlserver: # image: mcr.microsoft.com/mssql/server:2022-latest # ports: @@ -60,6 +69,7 @@ services: links: - mariadb:db - postgres + - redis - elasticsearch - opensearch ports: @@ -107,6 +117,7 @@ services: links: - mariadb:db - postgres + - redis - elasticsearch - opensearch volumes: @@ -136,6 +147,7 @@ services: links: - mariadb:db - postgres + - redis - elasticsearch - opensearch ports: diff --git a/docs/installation.md b/docs/installation.md index b9e9c335d3..8f52e201ed 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -67,6 +67,10 @@ The PDO extension is the preferred way to connect to your database server. - [Elasticsearch](https://www.elastic.co/products/elasticsearch) 7.x or 8.x - [OpenSearch](https://opensearch.org/) 2.x +### Optional In-Memory Data Store + +- [Redis](https://redis.io/) 8.x or later + ### Additional requirements - correctly set: access permissions, owner, group diff --git a/phpmyfaq/src/phpMyFAQ/Bootstrap/PhpConfigurator.php b/phpmyfaq/src/phpMyFAQ/Bootstrap/PhpConfigurator.php index 509f906150..698262fe8f 100644 --- a/phpmyfaq/src/phpMyFAQ/Bootstrap/PhpConfigurator.php +++ b/phpmyfaq/src/phpMyFAQ/Bootstrap/PhpConfigurator.php @@ -19,6 +19,10 @@ namespace phpMyFAQ\Bootstrap; +use phpMyFAQ\Configuration; +use phpMyFAQ\Session\RedisSessionHandler; +use RuntimeException; + class PhpConfigurator { /** @@ -53,7 +57,7 @@ public static function registerErrorHandlers(): void /** * Configures secure session settings if no session is active yet. */ - public static function configureSession(): void + public static function configureSession(?Configuration $configuration = null): void { if (session_status() !== PHP_SESSION_ACTIVE) { ini_set('session.use_only_cookies', value: '1'); @@ -65,6 +69,23 @@ public static function configureSession(): void if (defined('PMF_SESSION_SAVE_PATH') && PMF_SESSION_SAVE_PATH !== '') { ini_set('session.save_path', value: PMF_SESSION_SAVE_PATH); } + + $sessionHandler = strtolower((string) ($configuration?->get('session.handler') ?? 'files')); + $redisDsn = trim((string) ($configuration?->get('session.redisDsn') ?? '')); + + switch ($sessionHandler) { + case 'files': + ini_set('session.save_handler', value: 'files'); + break; + case 'redis': + RedisSessionHandler::configure($redisDsn); + break; + default: + throw new RuntimeException(sprintf( + 'Unsupported session handler "%s". Allowed values: files, redis.', + $sessionHandler, + )); + } } } } diff --git a/phpmyfaq/src/phpMyFAQ/Bootstrapper.php b/phpmyfaq/src/phpMyFAQ/Bootstrapper.php index ec49b54f92..a4ae6ecc11 100644 --- a/phpmyfaq/src/phpMyFAQ/Bootstrapper.php +++ b/phpmyfaq/src/phpMyFAQ/Bootstrapper.php @@ -85,7 +85,7 @@ public function run(): self $this->connectDatabase($databaseFile); // 12. Session configuration - PhpConfigurator::configureSession(); + PhpConfigurator::configureSession($this->faqConfig); // 13. LDAP $this->configureLdap(); diff --git a/phpmyfaq/src/phpMyFAQ/Session/RedisSessionHandler.php b/phpmyfaq/src/phpMyFAQ/Session/RedisSessionHandler.php new file mode 100644 index 0000000000..fa1897d978 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Session/RedisSessionHandler.php @@ -0,0 +1,97 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-02-14 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Session; + +use RuntimeException; + +class RedisSessionHandler +{ + public const string DEFAULT_DSN = 'tcp://redis:6379?database=0'; + + public static function configure(string $dsn = '', bool $validate = false): void + { + if (!extension_loaded('redis')) { + throw new RuntimeException('Redis session handler requires the PHP redis extension (ext-redis).'); + } + + $redisDsn = trim($dsn) !== '' ? trim($dsn) : self::DEFAULT_DSN; + + if ($validate) { + self::validateConnection($redisDsn); + } + + ini_set('session.save_handler', value: 'redis'); + ini_set('session.save_path', value: $redisDsn); + } + + public static function validateConnection(string $dsn, float $timeoutSeconds = 1.0): void + { + [$socketTarget, $displayTarget] = self::buildSocketTarget($dsn); + + $errno = 0; + $errorString = ''; + $connection = @stream_socket_client( + $socketTarget, + $errno, + $errorString, + $timeoutSeconds, + STREAM_CLIENT_CONNECT, + ); + + if ($connection === false) { + throw new RuntimeException(sprintf( + 'Redis session handler is configured but unreachable (%s): %s', + $displayTarget, + $errorString !== '' ? $errorString : 'connection failed', + )); + } + + fclose($connection); + } + + /** + * @return array{0: string, 1: string} + */ + private static function buildSocketTarget(string $dsn): array + { + $parsedUrl = parse_url($dsn); + if ($parsedUrl === false || !isset($parsedUrl['scheme'])) { + throw new RuntimeException('Invalid Redis DSN for sessions.'); + } + + $scheme = strtolower((string) $parsedUrl['scheme']); + if ($scheme === 'redis' || $scheme === 'tcp') { + $host = $parsedUrl['host'] ?? '127.0.0.1'; + $port = (int) ($parsedUrl['port'] ?? 6379); + return [sprintf('tcp://%s:%d', $host, $port), sprintf('%s:%d', $host, $port)]; + } + + if ($scheme === 'unix') { + $path = $parsedUrl['path'] ?? ''; + if ($path === '') { + throw new RuntimeException('Invalid Redis unix socket DSN for sessions.'); + } + + return ['unix://' . $path, $path]; + } + + throw new RuntimeException(sprintf('Unsupported Redis DSN scheme "%s" for sessions.', $scheme)); + } +} diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php b/phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php index 1b9e59b419..02e3a6ad06 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Installation/DefaultDataSeeder.php @@ -285,6 +285,8 @@ private static function buildDefaultConfig(): array 'api.onlyPublicQuestions' => 'true', 'api.ignoreOrphanedFaqs' => 'true', 'queue.transport' => 'database', + 'session.handler' => 'files', + 'session.redisDsn' => 'tcp://redis:6379?database=0', 'upgrade.dateLastChecked' => '', 'upgrade.lastDownloadedPackage' => '', 'upgrade.onlineUpdateEnabled' => 'false', diff --git a/phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php index 73f44efd91..9ff611481b 100644 --- a/phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php +++ b/phpmyfaq/src/phpMyFAQ/Setup/Migration/Versions/Migration420Alpha.php @@ -210,6 +210,8 @@ public function up(OperationRecorder $recorder): void $recorder->addConfig('api.rateLimit.requests', '100'); $recorder->addConfig('api.rateLimit.interval', '3600'); $recorder->addConfig('queue.transport', 'database'); + $recorder->addConfig('session.handler', 'files'); + $recorder->addConfig('session.redisDsn', 'tcp://redis:6379?database=0'); $recorder->addConfig('mail.useQueue', 'true'); $recorder->addConfig('mail.provider', 'smtp'); $recorder->addConfig('mail.sendgridApiKey', ''); diff --git a/tests/phpMyFAQ/Bootstrap/PhpConfiguratorTest.php b/tests/phpMyFAQ/Bootstrap/PhpConfiguratorTest.php index a08336de8e..d812b41891 100644 --- a/tests/phpMyFAQ/Bootstrap/PhpConfiguratorTest.php +++ b/tests/phpMyFAQ/Bootstrap/PhpConfiguratorTest.php @@ -2,12 +2,57 @@ namespace phpMyFAQ\Bootstrap; +use phpMyFAQ\Configuration; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use RuntimeException; +#[AllowMockObjectsWithoutExpectations] #[CoversClass(PhpConfigurator::class)] class PhpConfiguratorTest extends TestCase { + /** @var array */ + private array $iniBackup = []; + + protected function setUp(): void + { + parent::setUp(); + + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + + $this->iniBackup = [ + 'session.save_handler' => ini_get('session.save_handler'), + 'session.save_path' => ini_get('session.save_path'), + 'session.use_only_cookies' => ini_get('session.use_only_cookies'), + 'session.use_trans_sid' => ini_get('session.use_trans_sid'), + 'session.cookie_samesite' => ini_get('session.cookie_samesite'), + 'session.cookie_httponly' => ini_get('session.cookie_httponly'), + 'session.cookie_secure' => ini_get('session.cookie_secure'), + ]; + } + + protected function tearDown(): void + { + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); + } + + foreach ($this->iniBackup as $name => $value) { + if ($name === 'session.save_handler') { + continue; + } + + if ($value !== false) { + ini_set($name, (string) $value); + } + } + + parent::tearDown(); + } + public function testFixIncludePathEnsuresDotIsPresent(): void { PhpConfigurator::fixIncludePath(); @@ -23,4 +68,86 @@ public function testConfigurePcreSetsLimits(): void $this->assertEquals('100000000', ini_get('pcre.backtrack_limit')); $this->assertEquals('100000000', ini_get('pcre.recursion_limit')); } + + public function testConfigureSessionDefaultsToFilesHandler(): void + { + $configuration = $this->createMock(Configuration::class); + $configuration + ->method('get') + ->willReturnMap([ + ['session.handler', 'files'], + ['session.redisDsn', ''], + ]); + + PhpConfigurator::configureSession($configuration); + + $this->assertEquals('files', ini_get('session.save_handler')); + } + + public function testConfigureSessionUsesFilesForDatabaseHandlerInLiteMode(): void + { + $configuration = $this->createMock(Configuration::class); + $configuration + ->method('get') + ->willReturnMap([ + ['session.handler', 'files'], + ['session.redisDsn', ''], + ]); + + PhpConfigurator::configureSession($configuration); + + $this->assertEquals('files', ini_get('session.save_handler')); + } + + public function testConfigureSessionThrowsForUnsupportedHandler(): void + { + $configuration = $this->createMock(Configuration::class); + $configuration + ->method('get') + ->willReturnMap([ + ['session.handler', 'invalid'], + ['session.redisDsn', ''], + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unsupported session handler'); + + PhpConfigurator::configureSession($configuration); + } + + public function testConfigureSessionPersistsDataWithFilesHandler(): void + { + $sessionDirectory = sys_get_temp_dir() . '/pmf-session-test-' . uniqid('', true); + mkdir($sessionDirectory, 0777, true); + ini_set('session.save_path', $sessionDirectory); + + $configuration = $this->createMock(Configuration::class); + $configuration + ->method('get') + ->willReturnMap([ + ['session.handler', 'files'], + ['session.redisDsn', ''], + ]); + + PhpConfigurator::configureSession($configuration); + + session_id(''); + session_start(); + $_SESSION['phase7'] = 'ok'; + $sessionId = session_id(); + session_write_close(); + + session_id($sessionId); + session_start(); + $value = $_SESSION['phase7'] ?? null; + session_write_close(); + + $this->assertSame('ok', $value); + + $sessionFile = $sessionDirectory . '/sess_' . $sessionId; + if (file_exists($sessionFile)) { + unlink($sessionFile); + } + rmdir($sessionDirectory); + } } diff --git a/tests/phpMyFAQ/Session/RedisSessionHandlerTest.php b/tests/phpMyFAQ/Session/RedisSessionHandlerTest.php new file mode 100644 index 0000000000..c19e42de18 --- /dev/null +++ b/tests/phpMyFAQ/Session/RedisSessionHandlerTest.php @@ -0,0 +1,27 @@ +expectException(RuntimeException::class); + $this->expectExceptionMessage('Unsupported Redis DSN scheme'); + + RedisSessionHandler::validateConnection('http://redis:6379'); + } + + public function testValidateConnectionThrowsForUnreachableTarget(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Redis session handler is configured but unreachable'); + + RedisSessionHandler::validateConnection('tcp://127.0.0.1:1', 0.1); + } +} diff --git a/tests/phpMyFAQ/Setup/Installation/DefaultDataSeederTest.php b/tests/phpMyFAQ/Setup/Installation/DefaultDataSeederTest.php index a58885b833..236753c0e0 100644 --- a/tests/phpMyFAQ/Setup/Installation/DefaultDataSeederTest.php +++ b/tests/phpMyFAQ/Setup/Installation/DefaultDataSeederTest.php @@ -29,6 +29,8 @@ public function testGetMainConfigContainsRequiredKeys(): void $this->assertArrayHasKey('main.phpMyFAQToken', $config); $this->assertArrayHasKey('security.permLevel', $config); $this->assertArrayHasKey('spam.enableCaptchaCode', $config); + $this->assertArrayHasKey('session.handler', $config); + $this->assertArrayHasKey('session.redisDsn', $config); } public function testGetMainConfigHasDynamicValues(): void diff --git a/tests/phpMyFAQ/Setup/Migration/Versions/Migration420AlphaTest.php b/tests/phpMyFAQ/Setup/Migration/Versions/Migration420AlphaTest.php index 72d48e4361..9ec7e84763 100644 --- a/tests/phpMyFAQ/Setup/Migration/Versions/Migration420AlphaTest.php +++ b/tests/phpMyFAQ/Setup/Migration/Versions/Migration420AlphaTest.php @@ -104,6 +104,8 @@ public function testUpAddsRateLimitConfigEntries(): void $this->assertContains('api.rateLimit.requests', $addedConfigKeys); $this->assertContains('api.rateLimit.interval', $addedConfigKeys); $this->assertContains('queue.transport', $addedConfigKeys); + $this->assertContains('session.handler', $addedConfigKeys); + $this->assertContains('session.redisDsn', $addedConfigKeys); } public function testUpAddsFaqrateLimitsTableSql(): void