From f6761843b2ba739ab9d9afa263f6247854847d42 Mon Sep 17 00:00:00 2001
From: Sami Mazouz <sychocouldy@gmail.com>
Date: Wed, 14 Sep 2022 18:10:30 +0100
Subject: [PATCH] feat: customizable session driver (#3610)

---
 framework/core/src/Extend/Session.php         |  42 +++++++
 .../src/Foundation/Console/InfoCommand.php    |  70 ++++++++++-
 .../core/src/User/SessionDriverInterface.php  |  27 ++++
 framework/core/src/User/SessionManager.php    |  46 +++++++
 .../core/src/User/SessionServiceProvider.php  |  46 +++++--
 .../integration/extenders/SessionTest.php     | 116 ++++++++++++++++++
 .../testing/src/integration/TestCase.php      |   2 +-
 7 files changed, 340 insertions(+), 9 deletions(-)
 create mode 100644 framework/core/src/Extend/Session.php
 create mode 100644 framework/core/src/User/SessionDriverInterface.php
 create mode 100644 framework/core/src/User/SessionManager.php
 create mode 100644 framework/core/tests/integration/extenders/SessionTest.php

diff --git a/framework/core/src/Extend/Session.php b/framework/core/src/Extend/Session.php
new file mode 100644
index 000000000..83d4af8a1
--- /dev/null
+++ b/framework/core/src/Extend/Session.php
@@ -0,0 +1,42 @@
+<?php
+
+/*
+ * This file is part of Flarum.
+ *
+ * For detailed copyright and license information, please view the
+ * LICENSE file that was distributed with this source code.
+ */
+
+namespace Flarum\Extend;
+
+use Flarum\Extension\Extension;
+use Illuminate\Contracts\Container\Container;
+
+class Session implements ExtenderInterface
+{
+    private $drivers = [];
+
+    /**
+     * Register a new session driver.
+     *
+     * A driver can currently be selected by setting `session.driver` in `config.php`.
+     *
+     * @param string $name: The name of the driver.
+     * @param string $driverClass: The ::class attribute of the driver.
+     *                             Driver must implement `\Flarum\User\SessionDriverInterface`.
+     * @return self
+     */
+    public function driver(string $name, string $driverClass): self
+    {
+        $this->drivers[$name] = $driverClass;
+
+        return $this;
+    }
+
+    public function extend(Container $container, Extension $extension = null)
+    {
+        $container->extend('flarum.session.drivers', function ($drivers) {
+            return array_merge($drivers, $this->drivers);
+        });
+    }
+}
diff --git a/framework/core/src/Foundation/Console/InfoCommand.php b/framework/core/src/Foundation/Console/InfoCommand.php
index 383710bd5..f7bd3e80b 100644
--- a/framework/core/src/Foundation/Console/InfoCommand.php
+++ b/framework/core/src/Foundation/Console/InfoCommand.php
@@ -14,10 +14,14 @@ use Flarum\Extension\ExtensionManager;
 use Flarum\Foundation\Application;
 use Flarum\Foundation\Config;
 use Flarum\Settings\SettingsRepositoryInterface;
+use Flarum\User\SessionManager;
 use Illuminate\Contracts\Queue\Queue;
 use Illuminate\Database\ConnectionInterface;
+use Illuminate\Support\Arr;
 use Illuminate\Support\Str;
+use InvalidArgumentException;
 use PDO;
+use SessionHandlerInterface;
 use Symfony\Component\Console\Helper\Table;
 use Symfony\Component\Console\Helper\TableStyle;
 
@@ -48,18 +52,32 @@ class InfoCommand extends AbstractCommand
      */
     private $queue;
 
+    /**
+     * @var SessionManager
+     */
+    private $session;
+
+    /**
+     * @var SessionHandlerInterface
+     */
+    private $sessionHandler;
+
     public function __construct(
         ExtensionManager $extensions,
         Config $config,
         SettingsRepositoryInterface $settings,
         ConnectionInterface $db,
-        Queue $queue
+        Queue $queue,
+        SessionManager $session,
+        SessionHandlerInterface $sessionHandler
     ) {
         $this->extensions = $extensions;
         $this->config = $config;
         $this->settings = $settings;
         $this->db = $db;
         $this->queue = $queue;
+        $this->session = $session;
+        $this->sessionHandler = $sessionHandler;
 
         parent::__construct();
     }
@@ -93,6 +111,7 @@ class InfoCommand extends AbstractCommand
         $this->output->writeln('<info>Base URL:</info> '.$this->config->url());
         $this->output->writeln('<info>Installation path:</info> '.getcwd());
         $this->output->writeln('<info>Queue driver:</info> '.$this->identifyQueueDriver());
+        $this->output->writeln('<info>Session driver:</info> '.$this->identifySessionDriver());
         $this->output->writeln('<info>Mail driver:</info> '.$this->settings->get('mail_driver', 'unknown'));
         $this->output->writeln('<info>Debug mode:</info> '.($this->config->inDebugMode() ? '<error>ON</error>' : 'off'));
 
@@ -169,4 +188,53 @@ class InfoCommand extends AbstractCommand
     {
         return $this->db->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION);
     }
+
+    /**
+     * Reports on the session driver in use based on three scenarios:
+     *  1. If the configured session driver is valid and in use, it will be returned.
+     *  2. If the configured session driver is invalid, fallback to the default one and mention it.
+     *  3. If the actual used driver (i.e `session.handler`) is different from the current one (configured or default), mention it.
+     */
+    private function identifySessionDriver(): string
+    {
+        /*
+         * Get the configured driver and fallback to the default one.
+         */
+        $defaultDriver = $this->session->getDefaultDriver();
+        $configuredDriver = Arr::get($this->config, 'session.driver', $defaultDriver);
+        $driver = $configuredDriver;
+
+        try {
+            // Try to get the configured driver instance.
+            // Driver instances are created on demand.
+            $this->session->driver($configuredDriver);
+        } catch (InvalidArgumentException $e) {
+            // An exception is thrown if the configured driver is not a valid driver.
+            // So we fallback to the default driver.
+            $driver = $defaultDriver;
+        }
+
+        /*
+         * Get actual driver name from its class name.
+         * And compare that to the current configured driver.
+         */
+        // Get class name
+        $handlerName = get_class($this->sessionHandler);
+        // Drop the namespace
+        $handlerName = Str::afterLast($handlerName, '\\');
+        // Lowercase the class name
+        $handlerName = strtolower($handlerName);
+        // Drop everything like sessionhandler FileSessionHandler, DatabaseSessionHandler ..etc
+        $handlerName = str_replace('sessionhandler', '', $handlerName);
+
+        if ($driver !== $handlerName) {
+            return "$handlerName <comment>(Code override. Configured to <options=bold,underscore>$configuredDriver</>)</comment>";
+        }
+
+        if ($driver !== $configuredDriver) {
+            return "$driver <comment>(Fallback default driver. Configured to invalid driver <options=bold,underscore>$configuredDriver</>)</comment>";
+        }
+
+        return $driver;
+    }
 }
diff --git a/framework/core/src/User/SessionDriverInterface.php b/framework/core/src/User/SessionDriverInterface.php
new file mode 100644
index 000000000..7a0b284f7
--- /dev/null
+++ b/framework/core/src/User/SessionDriverInterface.php
@@ -0,0 +1,27 @@
+<?php
+
+/*
+ * This file is part of Flarum.
+ *
+ * For detailed copyright and license information, please view the
+ * LICENSE file that was distributed with this source code.
+ */
+
+namespace Flarum\User;
+
+use Flarum\Foundation\Config;
+use Flarum\Settings\SettingsRepositoryInterface;
+use SessionHandlerInterface;
+
+interface SessionDriverInterface
+{
+    /**
+     * Build a session handler to handle sessions.
+     * Settings and configuration can either be pulled from the Flarum settings repository
+     * or the config.php file.
+     *
+     * @param SettingsRepositoryInterface $settings: An instance of the Flarum settings repository.
+     * @param Config $config: An instance of the wrapper class around `config.php`.
+     */
+    public function build(SettingsRepositoryInterface $settings, Config $config): SessionHandlerInterface;
+}
diff --git a/framework/core/src/User/SessionManager.php b/framework/core/src/User/SessionManager.php
new file mode 100644
index 000000000..f550cdc11
--- /dev/null
+++ b/framework/core/src/User/SessionManager.php
@@ -0,0 +1,46 @@
+<?php
+
+/*
+ * This file is part of Flarum.
+ *
+ * For detailed copyright and license information, please view the
+ * LICENSE file that was distributed with this source code.
+ */
+
+namespace Flarum\User;
+
+use Flarum\Foundation\Config;
+use Illuminate\Session\SessionManager as IlluminateSessionManager;
+use Illuminate\Support\Arr;
+use InvalidArgumentException;
+use Psr\Log\LoggerInterface;
+use SessionHandlerInterface;
+
+class SessionManager extends IlluminateSessionManager
+{
+    /**
+     * Returns the configured session handler.
+     * Picks up the driver from `config.php` using the `session.driver` item.
+     * Falls back to the default driver if the configured one is not available,
+     *  and logs a critical error in that case.
+     */
+    public function handler(): SessionHandlerInterface
+    {
+        $config = $this->container->make(Config::class);
+        $driverName = Arr::get($config, 'session.driver');
+
+        try {
+            $driverInstance = parent::driver($driverName);
+        } catch (InvalidArgumentException $e) {
+            $defaultDriverName = $this->getDefaultDriver();
+            $driverInstance = parent::driver($defaultDriverName);
+
+            // But we will log a critical error to the webmaster.
+            $this->container->make(LoggerInterface::class)->critical(
+                "The configured session driver [$driverName] is not available. Falling back to default [$defaultDriverName]. Please check your configuration."
+            );
+        }
+
+        return $driverInstance->getHandler();
+    }
+}
diff --git a/framework/core/src/User/SessionServiceProvider.php b/framework/core/src/User/SessionServiceProvider.php
index 2eb070547..e0f8023a2 100644
--- a/framework/core/src/User/SessionServiceProvider.php
+++ b/framework/core/src/User/SessionServiceProvider.php
@@ -10,7 +10,9 @@
 namespace Flarum\User;
 
 use Flarum\Foundation\AbstractServiceProvider;
-use Illuminate\Session\FileSessionHandler;
+use Flarum\Foundation\Config;
+use Flarum\Settings\SettingsRepositoryInterface;
+use Illuminate\Contracts\Container\Container;
 use SessionHandlerInterface;
 
 class SessionServiceProvider extends AbstractServiceProvider
@@ -20,12 +22,42 @@ class SessionServiceProvider extends AbstractServiceProvider
      */
     public function register()
     {
-        $this->container->singleton('session.handler', function ($container) {
-            return new FileSessionHandler(
-                $container['files'],
-                $container['config']['session.files'],
-                $container['config']['session.lifetime']
-            );
+        $this->container->singleton('flarum.session.drivers', function () {
+            return [];
+        });
+
+        $this->container->singleton('session', function (Container $container) {
+            $manager = new SessionManager($container);
+            $drivers = $container->make('flarum.session.drivers');
+            $settings = $container->make(SettingsRepositoryInterface::class);
+            $config = $container->make(Config::class);
+
+            /**
+             * Default to the file driver already defined by Laravel.
+             *
+             * @see \Illuminate\Session\SessionManager::createFileDriver()
+             */
+            $manager->setDefaultDriver('file');
+
+            foreach ($drivers as $driver => $className) {
+                /** @var SessionDriverInterface $driverInstance */
+                $driverInstance = $container->make($className);
+
+                $manager->extend($driver, function () use ($settings, $config, $driverInstance) {
+                    return $driverInstance->build($settings, $config);
+                });
+            }
+
+            return $manager;
+        });
+
+        $this->container->alias('session', SessionManager::class);
+
+        $this->container->singleton('session.handler', function (Container $container): SessionHandlerInterface {
+            /** @var SessionManager $manager */
+            $manager = $container->make('session');
+
+            return $manager->handler();
         });
 
         $this->container->alias('session.handler', SessionHandlerInterface::class);
diff --git a/framework/core/tests/integration/extenders/SessionTest.php b/framework/core/tests/integration/extenders/SessionTest.php
new file mode 100644
index 000000000..82efc5456
--- /dev/null
+++ b/framework/core/tests/integration/extenders/SessionTest.php
@@ -0,0 +1,116 @@
+<?php
+
+/*
+ * This file is part of Flarum.
+ *
+ * For detailed copyright and license information, please view the
+ * LICENSE file that was distributed with this source code.
+ */
+
+namespace Flarum\Tests\integration\extenders;
+
+use Flarum\Extend;
+use Flarum\Foundation\Config;
+use Flarum\Settings\SettingsRepositoryInterface;
+use Flarum\Testing\integration\RetrievesAuthorizedUsers;
+use Flarum\Testing\integration\TestCase;
+use Flarum\User\SessionDriverInterface;
+use Illuminate\Session\FileSessionHandler;
+use Illuminate\Session\NullSessionHandler;
+use InvalidArgumentException;
+use SessionHandlerInterface;
+
+class SessionTest extends TestCase
+{
+    use RetrievesAuthorizedUsers;
+
+    /**
+     * @test
+     */
+    public function default_driver_exists_by_default()
+    {
+        $this->expectNotToPerformAssertions();
+        $this->app()->getContainer()->make('session.handler');
+    }
+
+    /**
+     * @test
+     */
+    public function custom_driver_doesnt_exist_by_default()
+    {
+        $this->expectException(InvalidArgumentException::class);
+        $this->app()->getContainer()->make('session')->driver('flarum-acme');
+    }
+
+    /**
+     * @test
+     */
+    public function custom_driver_exists_if_added()
+    {
+        $this->extend((new Extend\Session())->driver('flarum-acme', AcmeSessionDriver::class));
+
+        $driver = $this->app()->getContainer()->make('session')->driver('flarum-acme');
+
+        $this->assertEquals(NullSessionHandler::class, get_class($driver->getHandler()));
+    }
+
+    /**
+     * @test
+     */
+    public function custom_driver_overrides_laravel_defined_drivers_if_added()
+    {
+        $this->extend((new Extend\Session())->driver('redis', AcmeSessionDriver::class));
+
+        $driver = $this->app()->getContainer()->make('session')->driver('redis');
+
+        $this->assertEquals(NullSessionHandler::class, get_class($driver->getHandler()));
+    }
+
+    /**
+     * @test
+     */
+    public function uses_default_driver_if_driver_from_config_file_not_configured()
+    {
+        $this->config('session.driver', null);
+
+        $handler = $this->app()->getContainer()->make('session.handler');
+
+        $this->assertEquals(FileSessionHandler::class, get_class($handler));
+    }
+
+    /**
+     * @test
+     */
+    public function uses_default_driver_if_configured_driver_from_config_file_unavailable()
+    {
+        $this->config('session.driver', 'nevergonnagiveyouup');
+
+        $handler = $this->app()->getContainer()->make('session.handler');
+
+        $this->assertEquals(FileSessionHandler::class, get_class($handler));
+    }
+
+    /**
+     * @test
+     */
+    public function uses_custom_driver_from_config_file_if_configured_and_available()
+    {
+        $this->extend(
+            (new Extend\Session)->driver('flarum-acme', AcmeSessionDriver::class)
+        );
+
+        $this->config('session.driver', 'flarum-acme');
+
+        $handler = $this->app()->getContainer()->make('session.handler');
+
+        $this->assertEquals(NullSessionHandler::class, get_class($handler));
+    }
+}
+
+class AcmeSessionDriver implements SessionDriverInterface
+{
+    public function build(SettingsRepositoryInterface $settings, Config $config): SessionHandlerInterface
+    {
+        return new NullSessionHandler();
+    }
+}
diff --git a/php-packages/testing/src/integration/TestCase.php b/php-packages/testing/src/integration/TestCase.php
index 79a7a7f25..4efcec34c 100644
--- a/php-packages/testing/src/integration/TestCase.php
+++ b/php-packages/testing/src/integration/TestCase.php
@@ -148,7 +148,7 @@ abstract class TestCase extends \PHPUnit\Framework\TestCase
      */
     protected function config(string $key, $value)
     {
-        $this->config[$key] = $value;
+        Arr::set($this->config, $key, $value);
     }
 
     /**