From 790d5beee5e283178716bc8f9901c758d9e5b6a0 Mon Sep 17 00:00:00 2001 From: Franz Liedke Date: Wed, 24 Oct 2018 22:33:45 +0200 Subject: [PATCH] Split up the installer logic This is probably the most complicated way I could find to fix #1587. Jokes aside, this was done with a few goals in mind: - Reduce coupling between the installer and the rest of Flarum's "Application", which we are building during installation. - Move the installer logic to several smaller classes, which can then be used by the web frontend and the console task, instead of the former hacking its way into the latter to be "DRY". - Separate installer infrastructure (the "pipeline", with the ability to revert steps upon failure) from the actual steps being taken. The problem was conceptual, and would certainly re-occur in a similar fashion if we wouldn't tackle it at its roots. It is fixed now, because we no longer use the ExtensionManager for enabling extensions, but instead duplicate some of its logic. That is fine because we don't want to do everything it does, e.g. omit extenders' lifecycle hooks (which depend on the Application instance being complete). > for each desired change, make the change easy (warning: this may be > hard), then make the easy change - Kent Beck, https://twitter.com/kentbeck/status/250733358307500032 Fixes #1587. --- src/Extension/Extension.php | 38 ++ src/Extension/ExtensionManager.php | 18 +- src/Install/Console/DefaultsDataProvider.php | 17 +- src/Install/Console/InstallCommand.php | 337 ++++-------------- src/Install/Console/UserDataProvider.php | 17 +- src/Install/Controller/IndexController.php | 17 +- src/Install/Controller/InstallController.php | 92 ++--- src/Install/InstallServiceProvider.php | 36 +- src/Install/Installation.php | 178 +++++++++ src/Install/Installer.php | 7 +- src/Install/Pipeline.php | 108 ++++++ src/Install/ReversibleStep.php | 17 + src/Install/Step.php | 33 ++ src/Install/StepFailed.php | 18 + src/Install/Steps/BuildConfig.php | 75 ++++ src/Install/Steps/ConnectToDatabase.php | 57 +++ src/Install/Steps/CreateAdminUser.php | 63 ++++ src/Install/Steps/EnableBundledExtensions.php | 112 ++++++ src/Install/Steps/PublishAssets.php | 47 +++ src/Install/Steps/RunMigrations.php | 60 ++++ src/Install/Steps/StoreConfig.php | 46 +++ src/Install/Steps/WriteSettings.php | 52 +++ 22 files changed, 1077 insertions(+), 368 deletions(-) create mode 100644 src/Install/Installation.php create mode 100644 src/Install/Pipeline.php create mode 100644 src/Install/ReversibleStep.php create mode 100644 src/Install/Step.php create mode 100644 src/Install/StepFailed.php create mode 100644 src/Install/Steps/BuildConfig.php create mode 100644 src/Install/Steps/ConnectToDatabase.php create mode 100644 src/Install/Steps/CreateAdminUser.php create mode 100644 src/Install/Steps/EnableBundledExtensions.php create mode 100644 src/Install/Steps/PublishAssets.php create mode 100644 src/Install/Steps/RunMigrations.php create mode 100644 src/Install/Steps/StoreConfig.php create mode 100644 src/Install/Steps/WriteSettings.php diff --git a/src/Extension/Extension.php b/src/Extension/Extension.php index a85792961..a657c4e95 100644 --- a/src/Extension/Extension.php +++ b/src/Extension/Extension.php @@ -11,12 +11,18 @@ namespace Flarum\Extension; +use Flarum\Database\Migrator; use Flarum\Extend\Compat; use Flarum\Extend\LifecycleInterface; use Illuminate\Contracts\Container\Container; use Illuminate\Contracts\Support\Arrayable; use Illuminate\Support\Arr; use Illuminate\Support\Str; +use League\Flysystem\Adapter\Local; +use League\Flysystem\Filesystem; +use League\Flysystem\FilesystemInterface; +use League\Flysystem\MountManager; +use League\Flysystem\Plugin\ListFiles; /** * @property string $name @@ -307,6 +313,25 @@ class Extension implements Arrayable return realpath($this->path.'/assets/') !== false; } + public function copyAssetsTo(FilesystemInterface $target) + { + if (! $this->hasAssets()) { + return; + } + + $mount = new MountManager([ + 'source' => $source = new Filesystem(new Local($this->getPath().'/assets')), + 'target' => $target, + ]); + + $source->addPlugin(new ListFiles); + $assetFiles = $source->listFiles('/', true); + + foreach ($assetFiles as $file) { + $mount->copy("source://$file[path]", "target://extensions/$this->id/$file[path]"); + } + } + /** * Tests whether the extension has migrations. * @@ -317,6 +342,19 @@ class Extension implements Arrayable return realpath($this->path.'/migrations/') !== false; } + public function migrate(Migrator $migrator, $direction = 'up') + { + if (! $this->hasMigrations()) { + return; + } + + if ($direction == 'up') { + return $migrator->run($this->getPath().'/migrations', $this); + } else { + return $migrator->reset($this->getPath().'/migrations', $this); + } + } + /** * Generates an array result for the object. * diff --git a/src/Extension/ExtensionManager.php b/src/Extension/ExtensionManager.php index 45decbe94..221418367 100644 --- a/src/Extension/ExtensionManager.php +++ b/src/Extension/ExtensionManager.php @@ -222,26 +222,16 @@ class ExtensionManager * Runs the database migrations for the extension. * * @param Extension $extension - * @param bool|true $up + * @param string $direction * @return void */ - public function migrate(Extension $extension, $up = true) + public function migrate(Extension $extension, $direction = 'up') { - if (! $extension->hasMigrations()) { - return; - } - - $migrationDir = $extension->getPath().'/migrations'; - $this->app->bind('Illuminate\Database\Schema\Builder', function ($container) { return $container->make('Illuminate\Database\ConnectionInterface')->getSchemaBuilder(); }); - if ($up) { - $this->migrator->run($migrationDir, $extension); - } else { - $this->migrator->reset($migrationDir, $extension); - } + $extension->migrate($this->migrator, $direction); } /** @@ -252,7 +242,7 @@ class ExtensionManager */ public function migrateDown(Extension $extension) { - return $this->migrate($extension, false); + return $this->migrate($extension, 'down'); } /** diff --git a/src/Install/Console/DefaultsDataProvider.php b/src/Install/Console/DefaultsDataProvider.php index 70f300071..62841faea 100644 --- a/src/Install/Console/DefaultsDataProvider.php +++ b/src/Install/Console/DefaultsDataProvider.php @@ -14,13 +14,16 @@ namespace Flarum\Install\Console; class DefaultsDataProvider implements DataProviderInterface { protected $databaseConfiguration = [ - 'driver' => 'mysql', - 'host' => 'localhost', - 'database' => 'flarum', - 'username' => 'root', - 'password' => '', - 'prefix' => '', - 'port' => '3306', + 'driver' => 'mysql', + 'host' => 'localhost', + 'database' => 'flarum', + 'username' => 'root', + 'password' => '', + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'port' => '3306', + 'strict' => false, ]; protected $debug = false; diff --git a/src/Install/Console/InstallCommand.php b/src/Install/Console/InstallCommand.php index 37cd34655..e429d62d8 100644 --- a/src/Install/Console/InstallCommand.php +++ b/src/Install/Console/InstallCommand.php @@ -11,65 +11,41 @@ namespace Flarum\Install\Console; -use Carbon\Carbon; use Exception; use Flarum\Console\AbstractCommand; -use Flarum\Database\DatabaseMigrationRepository; -use Flarum\Database\Migrator; -use Flarum\Extension\ExtensionManager; -use Flarum\Foundation\Application as FlarumApplication; -use Flarum\Foundation\Site; -use Flarum\Group\Group; -use Flarum\Install\Prerequisite\PrerequisiteInterface; -use Flarum\Settings\DatabaseSettingsRepository; -use Illuminate\Contracts\Events\Dispatcher; -use Illuminate\Contracts\Foundation\Application; -use Illuminate\Contracts\Translation\Translator; -use Illuminate\Database\ConnectionInterface; -use Illuminate\Database\Connectors\ConnectionFactory; -use Illuminate\Filesystem\Filesystem; -use Illuminate\Hashing\BcryptHasher; -use Illuminate\Validation\Factory; -use PDO; +use Flarum\Install\Installation; +use Flarum\Install\Pipeline; +use Flarum\Install\Step; +use Illuminate\Contracts\Validation\Factory; use Symfony\Component\Console\Input\InputOption; class InstallCommand extends AbstractCommand { + /** + * @var Installation + */ + protected $installation; + + /** + * @var Factory + */ + protected $validator; + /** * @var DataProviderInterface */ protected $dataSource; /** - * @var Application + * @param Installation $installation + * @param Factory $validator */ - protected $application; - - /** - * @var Filesystem - */ - protected $filesystem; - - /** - * @var ConnectionInterface - */ - protected $db; - - /** - * @var Migrator - */ - protected $migrator; - - /** - * @param Application $application - * @param Filesystem $filesystem - */ - public function __construct(Application $application, Filesystem $filesystem) + public function __construct(Installation $installation, Factory $validator) { - $this->application = $application; + $this->installation = $installation; + $this->validator = $validator; parent::__construct(); - $this->filesystem = $filesystem; } protected function configure() @@ -104,7 +80,7 @@ class InstallCommand extends AbstractCommand { $this->init(); - $prerequisites = $this->getPrerequisites(); + $prerequisites = $this->installation->prerequisites(); $prerequisites->check(); $errors = $prerequisites->getErrors(); @@ -124,245 +100,82 @@ class InstallCommand extends AbstractCommand protected function init() { - if ($this->dataSource === null) { - if ($this->input->getOption('defaults')) { - $this->dataSource = new DefaultsDataProvider(); - } elseif ($this->input->getOption('file')) { - $this->dataSource = new FileDataProvider($this->input); - } else { - $this->dataSource = new UserDataProvider($this->input, $this->output, $this->getHelperSet()->get('question')); - } + if ($this->input->getOption('defaults')) { + $this->dataSource = new DefaultsDataProvider(); + } elseif ($this->input->getOption('file')) { + $this->dataSource = new FileDataProvider($this->input); + } else { + $this->dataSource = new UserDataProvider($this->input, $this->output, $this->getHelperSet()->get('question')); } } - public function setDataSource(DataProviderInterface $dataSource) - { - $this->dataSource = $dataSource; - } - protected function install() { - try { - $this->dbConfig = $this->dataSource->getDatabaseConfiguration(); + $dbConfig = $this->dataSource->getDatabaseConfiguration(); - $validation = $this->getValidator()->make( - $this->dbConfig, - [ - 'driver' => 'required|in:mysql', - 'host' => 'required', - 'database' => 'required|string', - 'username' => 'required|string', - 'prefix' => 'nullable|alpha_dash|max:10', - 'port' => 'nullable|integer|min:1|max:65535', - ] - ); - - if ($validation->fails()) { - throw new Exception(implode("\n", call_user_func_array('array_merge', $validation->getMessageBag()->toArray()))); - } - - $this->baseUrl = $this->dataSource->getBaseUrl(); - $this->settings = $this->dataSource->getSettings(); - $this->adminUser = $admin = $this->dataSource->getAdminUser(); - - if (strlen($admin['password']) < 8) { - throw new Exception('Password must be at least 8 characters.'); - } - - if ($admin['password'] !== $admin['password_confirmation']) { - throw new Exception('The password did not match its confirmation.'); - } - - if (! filter_var($admin['email'], FILTER_VALIDATE_EMAIL)) { - throw new Exception('You must enter a valid email.'); - } - - if (! $admin['username'] || preg_match('/[^a-z0-9_-]/i', $admin['username'])) { - throw new Exception('Username can only contain letters, numbers, underscores, and dashes.'); - } - - $this->storeConfiguration($this->dataSource->isDebugMode()); - - $this->runMigrations(); - - $this->writeSettings(); - - $this->createAdminUser(); - - $this->publishAssets(); - - // Now that the installation of core is complete, boot up a new - // application instance before enabling extensions so that all of - // the application services are available. - Site::fromPaths([ - 'base' => $this->application->basePath(), - 'public' => $this->application->publicPath(), - 'storage' => $this->application->storagePath(), - ])->bootApp(); - - $this->application = FlarumApplication::getInstance(); - - $this->enableBundledExtensions(); - } catch (Exception $e) { - @unlink($this->getConfigFile()); - - throw $e; - } - } - - protected function storeConfiguration(bool $debugMode) - { - $dbConfig = $this->dbConfig; - - $config = [ - 'debug' => $debugMode, - 'database' => $laravelDbConfig = [ - 'driver' => $dbConfig['driver'], - 'host' => $dbConfig['host'], - 'database' => $dbConfig['database'], - 'username' => $dbConfig['username'], - 'password' => $dbConfig['password'], - 'charset' => 'utf8mb4', - 'collation' => 'utf8mb4_unicode_ci', - 'prefix' => $dbConfig['prefix'], - 'port' => $dbConfig['port'], - 'strict' => false - ], - 'url' => $this->baseUrl, - 'paths' => [ - 'api' => 'api', - 'admin' => 'admin', - ], - ]; - - $this->info('Testing config'); - - $factory = new ConnectionFactory($this->application); - - $laravelDbConfig['engine'] = 'InnoDB'; - - $this->db = $factory->make($laravelDbConfig); - $version = $this->db->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION); - - if (version_compare($version, '5.5.0', '<')) { - throw new Exception('MySQL version too low. You need at least MySQL 5.5.'); - } - - $repository = new DatabaseMigrationRepository( - $this->db, 'migrations' + $validation = $this->validator->make( + $dbConfig, + [ + 'driver' => 'required|in:mysql', + 'host' => 'required', + 'database' => 'required|string', + 'username' => 'required|string', + 'prefix' => 'nullable|alpha_dash|max:10', + 'port' => 'nullable|integer|min:1|max:65535', + ] ); - $files = $this->application->make('files'); - $this->migrator = new Migrator($repository, $this->db, $files); - - $this->info('Writing config'); - - file_put_contents( - $this->getConfigFile(), - 'migrator->setOutput($this->output); - $this->migrator->getRepository()->createRepository(); - $this->migrator->run(__DIR__.'/../../../migrations'); - } - - protected function writeSettings() - { - $settings = new DatabaseSettingsRepository($this->db); - - $this->info('Writing default settings'); - - $settings->set('version', $this->application->version()); - - foreach ($this->settings as $k => $v) { - $settings->set($k, $v); + if ($validation->fails()) { + throw new Exception(implode("\n", + call_user_func_array('array_merge', + $validation->getMessageBag()->toArray()))); } - } - protected function createAdminUser() - { - $admin = $this->adminUser; + $admin = $this->dataSource->getAdminUser(); + + if (strlen($admin['password']) < 8) { + throw new Exception('Password must be at least 8 characters.'); + } if ($admin['password'] !== $admin['password_confirmation']) { throw new Exception('The password did not match its confirmation.'); } - $this->info('Creating admin user '.$admin['username']); - - $uid = $this->db->table('users')->insertGetId([ - 'username' => $admin['username'], - 'email' => $admin['email'], - 'password' => (new BcryptHasher)->make($admin['password']), - 'joined_at' => Carbon::now(), - 'is_email_confirmed' => 1, - ]); - - $this->db->table('group_user')->insert([ - 'user_id' => $uid, - 'group_id' => Group::ADMINISTRATOR_ID, - ]); - } - - protected function enableBundledExtensions() - { - $extensions = new ExtensionManager( - new DatabaseSettingsRepository($this->db), - $this->application, - $this->migrator, - $this->application->make(Dispatcher::class), - $this->application->make('files') - ); - - $disabled = [ - 'flarum-akismet', - 'flarum-auth-facebook', - 'flarum-auth-github', - 'flarum-auth-twitter', - 'flarum-pusher', - ]; - - foreach ($extensions->getExtensions() as $name => $extension) { - if (in_array($name, $disabled)) { - continue; - } - - $this->info('Enabling extension: '.$name); - - $extensions->enable($name); + if (! filter_var($admin['email'], FILTER_VALIDATE_EMAIL)) { + throw new Exception('You must enter a valid email.'); } - } - protected function publishAssets() - { - $this->filesystem->copyDirectory( - $this->application->basePath().'/vendor/components/font-awesome/webfonts', - $this->application->publicPath().'/assets/fonts' + if (! $admin['username'] || preg_match('/[^a-z0-9_-]/i', + $admin['username'])) { + throw new Exception('Username can only contain letters, numbers, underscores, and dashes.'); + } + + $this->runPipeline( + $this->installation + ->configPath($this->input->getOption('config')) + ->debugMode($this->dataSource->isDebugMode()) + ->baseUrl($this->dataSource->getBaseUrl()) + ->databaseConfig($dbConfig) + ->adminUser($admin) + ->settings($this->dataSource->getSettings()) + ->build() ); } - protected function getConfigFile() + private function runPipeline(Pipeline $pipeline) { - return $this->input->getOption('config') ?: base_path('config.php'); - } - - /** - * @return \Flarum\Install\Prerequisite\PrerequisiteInterface - */ - protected function getPrerequisites() - { - return $this->application->make(PrerequisiteInterface::class); - } - - /** - * @return \Illuminate\Contracts\Validation\Factory - */ - protected function getValidator() - { - return new Factory($this->application->make(Translator::class)); + $pipeline + ->on('start', function (Step $step) { + $this->output->write($step->getMessage().'...'); + })->on('end', function () { + $this->output->write("done\n"); + })->on('fail', function () { + $this->output->write("failed\n"); + $this->output->writeln('Rolling back...'); + })->on('rollback', function (Step $step) { + $this->output->writeln($step->getMessage().' (rollback)'); + }) + ->run(); } protected function showErrors($errors) diff --git a/src/Install/Console/UserDataProvider.php b/src/Install/Console/UserDataProvider.php index a84df2050..eb10ef5ba 100644 --- a/src/Install/Console/UserDataProvider.php +++ b/src/Install/Console/UserDataProvider.php @@ -43,13 +43,16 @@ class UserDataProvider implements DataProviderInterface } return [ - 'driver' => 'mysql', - 'host' => $host, - 'port' => $port, - 'database' => $this->ask('Database name:'), - 'username' => $this->ask('Database user:'), - 'password' => $this->secret('Database password:'), - 'prefix' => $this->ask('Prefix:'), + 'driver' => 'mysql', + 'host' => $host, + 'port' => $port, + 'database' => $this->ask('Database name:'), + 'username' => $this->ask('Database user:'), + 'password' => $this->secret('Database password:'), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => $this->ask('Prefix:'), + 'strict' => false, ]; } diff --git a/src/Install/Controller/IndexController.php b/src/Install/Controller/IndexController.php index e96110969..03b1e2449 100644 --- a/src/Install/Controller/IndexController.php +++ b/src/Install/Controller/IndexController.php @@ -12,7 +12,7 @@ namespace Flarum\Install\Controller; use Flarum\Http\Controller\AbstractHtmlController; -use Flarum\Install\Prerequisite\PrerequisiteInterface; +use Flarum\Install\Installation; use Illuminate\Contracts\View\Factory; use Psr\Http\Message\ServerRequestInterface as Request; @@ -24,18 +24,18 @@ class IndexController extends AbstractHtmlController protected $view; /** - * @var \Flarum\Install\Prerequisite\PrerequisiteInterface + * @var Installation */ - protected $prerequisite; + protected $installation; /** * @param Factory $view - * @param PrerequisiteInterface $prerequisite + * @param Installation $installation */ - public function __construct(Factory $view, PrerequisiteInterface $prerequisite) + public function __construct(Factory $view, Installation $installation) { $this->view = $view; - $this->prerequisite = $prerequisite; + $this->installation = $installation; } /** @@ -46,8 +46,9 @@ class IndexController extends AbstractHtmlController { $view = $this->view->make('flarum.install::app')->with('title', 'Install Flarum'); - $this->prerequisite->check(); - $errors = $this->prerequisite->getErrors(); + $prerequisites = $this->installation->prerequisites(); + $prerequisites->check(); + $errors = $prerequisites->getErrors(); if (count($errors)) { $view->with('content', $this->view->make('flarum.install::errors')->with('errors', $errors)); diff --git a/src/Install/Controller/InstallController.php b/src/Install/Controller/InstallController.php index aaa13e277..835a6e312 100644 --- a/src/Install/Controller/InstallController.php +++ b/src/Install/Controller/InstallController.php @@ -13,19 +13,18 @@ namespace Flarum\Install\Controller; use Exception; use Flarum\Http\SessionAuthenticator; -use Flarum\Install\Console\DefaultsDataProvider; -use Flarum\Install\Console\InstallCommand; +use Flarum\Install\Installation; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\RequestHandlerInterface; -use Symfony\Component\Console\Input\StringInput; -use Symfony\Component\Console\Output\StreamOutput; use Zend\Diactoros\Response; -use Zend\Diactoros\Response\HtmlResponse; class InstallController implements RequestHandlerInterface { - protected $command; + /** + * @var Installation + */ + protected $installation; /** * @var SessionAuthenticator @@ -34,12 +33,12 @@ class InstallController implements RequestHandlerInterface /** * InstallController constructor. - * @param InstallCommand $command + * @param Installation $installation * @param SessionAuthenticator $authenticator */ - public function __construct(InstallCommand $command, SessionAuthenticator $authenticator) + public function __construct(Installation $installation, SessionAuthenticator $authenticator) { - $this->command = $command; + $this->installation = $installation; $this->authenticator = $authenticator; } @@ -51,8 +50,6 @@ class InstallController implements RequestHandlerInterface { $input = $request->getParsedBody(); - $data = new DefaultsDataProvider; - $host = array_get($input, 'mysqlHost'); $port = '3306'; @@ -60,45 +57,58 @@ class InstallController implements RequestHandlerInterface list($host, $port) = explode(':', $host, 2); } - $data->setDatabaseConfiguration([ - 'driver' => 'mysql', - 'host' => $host, - 'database' => array_get($input, 'mysqlDatabase'), - 'username' => array_get($input, 'mysqlUsername'), - 'password' => array_get($input, 'mysqlPassword'), - 'prefix' => array_get($input, 'tablePrefix'), - 'port' => $port, - ]); - - $data->setAdminUser([ - 'username' => array_get($input, 'adminUsername'), - 'password' => array_get($input, 'adminPassword'), - 'password_confirmation' => array_get($input, 'adminPasswordConfirmation'), - 'email' => array_get($input, 'adminEmail'), - ]); - $baseUrl = rtrim((string) $request->getUri(), '/'); - $data->setBaseUrl($baseUrl); - $data->setSetting('forum_title', array_get($input, 'forumTitle')); - $data->setSetting('mail_from', 'noreply@'.preg_replace('/^www\./i', '', parse_url($baseUrl, PHP_URL_HOST))); - $data->setSetting('welcome_title', 'Welcome to '.array_get($input, 'forumTitle')); - - $body = fopen('php://temp', 'wb+'); - $input = new StringInput(''); - $output = new StreamOutput($body); - - $this->command->setDataSource($data); + $pipeline = $this->installation + ->baseUrl($baseUrl) + ->databaseConfig([ + 'driver' => 'mysql', + 'host' => $host, + 'port' => $port, + 'database' => array_get($input, 'mysqlDatabase'), + 'username' => array_get($input, 'mysqlUsername'), + 'password' => array_get($input, 'mysqlPassword'), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => array_get($input, 'tablePrefix'), + 'strict' => false, + ]) + ->adminUser([ + 'username' => array_get($input, 'adminUsername'), + 'password' => array_get($input, 'adminPassword'), + 'password_confirmation' => array_get($input, 'adminPasswordConfirmation'), + 'email' => array_get($input, 'adminEmail'), + ]) + ->settings([ + 'allow_post_editing' => 'reply', + 'allow_renaming' => '10', + 'allow_sign_up' => '1', + 'custom_less' => '', + 'default_locale' => 'en', + 'default_route' => '/all', + 'extensions_enabled' => '[]', + 'forum_title' => array_get($input, 'forumTitle'), + 'forum_description' => '', + 'mail_driver' => 'mail', + 'mail_from' => 'noreply@'.preg_replace('/^www\./i', '', parse_url($baseUrl, PHP_URL_HOST)), + 'theme_colored_header' => '0', + 'theme_dark_mode' => '0', + 'theme_primary_color' => '#4D698E', + 'theme_secondary_color' => '#4D698E', + 'welcome_message' => 'This is beta software and you should not use it in production.', + 'welcome_title' => 'Welcome to '.array_get($input, 'forumTitle'), + ]) + ->build(); try { - $this->command->run($input, $output); + $pipeline->run(); } catch (Exception $e) { - return new HtmlResponse($e->getMessage(), 500); + return new Response\HtmlResponse($e->getMessage(), 500); } $session = $request->getAttribute('session'); $this->authenticator->logIn($session, 1); - return new Response($body); + return new Response\EmptyResponse; } } diff --git a/src/Install/InstallServiceProvider.php b/src/Install/InstallServiceProvider.php index 4a0c8ae03..1ea4fa07b 100644 --- a/src/Install/InstallServiceProvider.php +++ b/src/Install/InstallServiceProvider.php @@ -14,11 +14,6 @@ namespace Flarum\Install; use Flarum\Foundation\AbstractServiceProvider; use Flarum\Http\RouteCollection; use Flarum\Http\RouteHandlerFactory; -use Flarum\Install\Prerequisite\Composite; -use Flarum\Install\Prerequisite\PhpExtensions; -use Flarum\Install\Prerequisite\PhpVersion; -use Flarum\Install\Prerequisite\PrerequisiteInterface; -use Flarum\Install\Prerequisite\WritablePaths; class InstallServiceProvider extends AbstractServiceProvider { @@ -27,32 +22,17 @@ class InstallServiceProvider extends AbstractServiceProvider */ public function register() { - $this->app->bind( - PrerequisiteInterface::class, - function () { - return new Composite( - new PhpVersion('7.1.0'), - new PhpExtensions([ - 'dom', - 'gd', - 'json', - 'mbstring', - 'openssl', - 'pdo_mysql', - 'tokenizer', - ]), - new WritablePaths([ - base_path(), - public_path('assets'), - storage_path(), - ]) - ); - } - ); - $this->app->singleton('flarum.install.routes', function () { return new RouteCollection; }); + + $this->app->singleton(Installation::class, function () { + return new Installation( + $this->app->basePath(), + $this->app->publicPath(), + $this->app->storagePath() + ); + }); } /** diff --git a/src/Install/Installation.php b/src/Install/Installation.php new file mode 100644 index 000000000..0da25ca9f --- /dev/null +++ b/src/Install/Installation.php @@ -0,0 +1,178 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Install; + +use Flarum\Install\Prerequisite\Composite; +use Flarum\Install\Prerequisite\PhpExtensions; +use Flarum\Install\Prerequisite\PhpVersion; +use Flarum\Install\Prerequisite\PrerequisiteInterface; +use Flarum\Install\Prerequisite\WritablePaths; +use Flarum\Install\Steps\BuildConfig; +use Flarum\Install\Steps\ConnectToDatabase; +use Flarum\Install\Steps\CreateAdminUser; +use Flarum\Install\Steps\EnableBundledExtensions; +use Flarum\Install\Steps\PublishAssets; +use Flarum\Install\Steps\RunMigrations; +use Flarum\Install\Steps\StoreConfig; +use Flarum\Install\Steps\WriteSettings; + +class Installation +{ + private $basePath; + private $publicPath; + private $storagePath; + + private $configPath; + private $debug = false; + private $dbConfig = []; + private $baseUrl; + private $defaultSettings = []; + private $adminUser = []; + + public function __construct($basePath, $publicPath, $storagePath) + { + $this->basePath = $basePath; + $this->publicPath = $publicPath; + $this->storagePath = $storagePath; + } + + public function configPath($path) + { + $this->configPath = $path; + + return $this; + } + + public function debugMode($flag) + { + $this->debug = $flag; + + return $this; + } + + public function databaseConfig(array $dbConfig) + { + $this->dbConfig = $dbConfig; + + return $this; + } + + public function baseUrl($baseUrl) + { + $this->baseUrl = $baseUrl; + + return $this; + } + + public function settings($settings) + { + $this->defaultSettings = $settings; + + return $this; + } + + public function adminUser($admin) + { + $this->adminUser = $admin; + + return $this; + } + + public function prerequisites(): PrerequisiteInterface + { + return new Composite( + new PhpVersion('7.1.0'), + new PhpExtensions([ + 'dom', + 'gd', + 'json', + 'mbstring', + 'openssl', + 'pdo_mysql', + 'tokenizer', + ]), + new WritablePaths([ + $this->basePath, + $this->getAssetPath(), + $this->storagePath, + ]) + ); + } + + public function build(): Pipeline + { + $pipeline = new Pipeline; + + // A new array to persist some objects between steps. + // It's an instance variable so that access in closures is easier. :) + $this->tmp = []; + + $pipeline->pipe(function () { + return new BuildConfig( + $this->debug, $this->dbConfig, $this->baseUrl, + function ($config) { + $this->tmp['config'] = $config; + } + ); + }); + + $pipeline->pipe(function () { + return new ConnectToDatabase( + $this->dbConfig, + function ($connection) { + $this->tmp['db'] = $connection; + } + ); + }); + + $pipeline->pipe(function () { + return new StoreConfig($this->tmp['config'], $this->getConfigPath()); + }); + + $pipeline->pipe(function () { + return new RunMigrations($this->tmp['db'], $this->getMigrationPath()); + }); + + $pipeline->pipe(function () { + return new WriteSettings($this->tmp['db'], $this->defaultSettings); + }); + + $pipeline->pipe(function () { + return new CreateAdminUser($this->tmp['db'], $this->adminUser); + }); + + $pipeline->pipe(function () { + return new PublishAssets($this->basePath, $this->getAssetPath()); + }); + + $pipeline->pipe(function () { + return new EnableBundledExtensions($this->tmp['db'], $this->basePath, $this->getAssetPath()); + }); + + return $pipeline; + } + + private function getConfigPath() + { + return $this->basePath.'/'.($this->configPath ?? 'config.php'); + } + + private function getAssetPath() + { + return "$this->publicPath/assets"; + } + + private function getMigrationPath() + { + return __DIR__.'/../../migrations'; + } +} diff --git a/src/Install/Installer.php b/src/Install/Installer.php index 3df6a1b86..287a513fe 100644 --- a/src/Install/Installer.php +++ b/src/Install/Installer.php @@ -17,6 +17,8 @@ use Flarum\Http\Middleware\HandleErrorsWithWhoops; use Flarum\Http\Middleware\StartSession; use Flarum\Install\Console\InstallCommand; use Illuminate\Contracts\Container\Container; +use Illuminate\Contracts\Translation\Translator; +use Illuminate\Validation\Factory; use Zend\Stratigility\MiddlewarePipe; class Installer implements AppInterface @@ -52,7 +54,10 @@ class Installer implements AppInterface public function getConsoleCommands() { return [ - $this->container->make(InstallCommand::class), + new InstallCommand( + $this->container->make(Installation::class), + new Factory($this->container->make(Translator::class)) + ), ]; } } diff --git a/src/Install/Pipeline.php b/src/Install/Pipeline.php new file mode 100644 index 000000000..31c8cb1c9 --- /dev/null +++ b/src/Install/Pipeline.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Install; + +use Exception; +use SplStack; + +class Pipeline +{ + /** + * @var callable[] + */ + private $steps; + + /** + * @var callable[] + */ + private $callbacks; + + /** + * @var SplStack + */ + private $successfulSteps; + + public function __construct(array $steps = []) + { + $this->steps = $steps; + } + + public function pipe(callable $factory) + { + $this->steps[] = $factory; + + return $this; + } + + public function on($event, callable $callback) + { + $this->callbacks[$event] = $callback; + + return $this; + } + + public function run() + { + $this->successfulSteps = new SplStack; + + try { + foreach ($this->steps as $factory) { + $this->runStep($factory); + } + } catch (StepFailed $failure) { + $this->revertReversibleSteps(); + + throw $failure; + } + } + + /** + * @param callable $factory + * @throws StepFailed + */ + private function runStep(callable $factory) + { + /** @var Step $step */ + $step = $factory(); + + $this->fireCallbacks('start', $step); + + try { + $step->run(); + $this->successfulSteps->push($step); + + $this->fireCallbacks('end', $step); + } catch (Exception $e) { + $this->fireCallbacks('fail', $step); + + throw new StepFailed('Step failed', 0, $e); + } + } + + private function revertReversibleSteps() + { + foreach ($this->successfulSteps as $step) { + if ($step instanceof ReversibleStep) { + $this->fireCallbacks('rollback', $step); + + $step->revert(); + } + } + } + + private function fireCallbacks($event, Step $step) + { + if (isset($this->callbacks[$event])) { + ($this->callbacks[$event])($step); + } + } +} diff --git a/src/Install/ReversibleStep.php b/src/Install/ReversibleStep.php new file mode 100644 index 000000000..b65de4993 --- /dev/null +++ b/src/Install/ReversibleStep.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Install; + +interface ReversibleStep +{ + public function revert(); +} diff --git a/src/Install/Step.php b/src/Install/Step.php new file mode 100644 index 000000000..6b0de2658 --- /dev/null +++ b/src/Install/Step.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Install; + +interface Step +{ + /** + * A one-line status message summarizing what's happening in this step. + * + * @return string + */ + public function getMessage(); + + /** + * Do the work that constitutes this step. + * + * This method should raise a `StepFailed` exception whenever something goes + * wrong that should result in the entire installation being reverted. + * + * @return void + * @throws StepFailed + */ + public function run(); +} diff --git a/src/Install/StepFailed.php b/src/Install/StepFailed.php new file mode 100644 index 000000000..8fe1a3b7d --- /dev/null +++ b/src/Install/StepFailed.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Install; + +use Exception; + +class StepFailed extends Exception +{ +} diff --git a/src/Install/Steps/BuildConfig.php b/src/Install/Steps/BuildConfig.php new file mode 100644 index 000000000..bab1a160e --- /dev/null +++ b/src/Install/Steps/BuildConfig.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Install\Steps; + +use Flarum\Install\Step; + +class BuildConfig implements Step +{ + private $debugMode; + + private $dbConfig; + + private $baseUrl; + + private $store; + + public function __construct($debugMode, $dbConfig, $baseUrl, callable $store) + { + $this->debugMode = $debugMode; + $this->dbConfig = $dbConfig; + $this->baseUrl = $baseUrl; + + $this->store = $store; + } + + public function getMessage() + { + return 'Building config array'; + } + + public function run() + { + $config = [ + 'debug' => $this->debugMode, + 'database' => $this->getDatabaseConfig(), + 'url' => $this->baseUrl, + 'paths' => $this->getPathsConfig(), + ]; + + ($this->store)($config); + } + + private function getDatabaseConfig() + { + return [ + 'driver' => $this->dbConfig['driver'], + 'host' => $this->dbConfig['host'], + 'database' => $this->dbConfig['database'], + 'username' => $this->dbConfig['username'], + 'password' => $this->dbConfig['password'], + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => $this->dbConfig['prefix'], + 'port' => $this->dbConfig['port'], + 'strict' => false, + ]; + } + + private function getPathsConfig() + { + return [ + 'api' => 'api', + 'admin' => 'admin', + ]; + } +} diff --git a/src/Install/Steps/ConnectToDatabase.php b/src/Install/Steps/ConnectToDatabase.php new file mode 100644 index 000000000..1f8357b25 --- /dev/null +++ b/src/Install/Steps/ConnectToDatabase.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Install\Steps; + +use Flarum\Install\Step; +use Illuminate\Database\Connectors\MySqlConnector; +use Illuminate\Database\MySqlConnection; +use PDO; +use RangeException; + +class ConnectToDatabase implements Step +{ + private $dbConfig; + private $store; + + public function __construct($dbConfig, callable $store) + { + $this->dbConfig = $dbConfig; + $this->dbConfig['engine'] = 'InnoDB'; + + $this->store = $store; + } + + public function getMessage() + { + return 'Connecting to database'; + } + + public function run() + { + $pdo = (new MySqlConnector)->connect($this->dbConfig); + + $version = $pdo->getAttribute(PDO::ATTR_SERVER_VERSION); + + if (version_compare($version, '5.5.0', '<')) { + throw new RangeException('MySQL version too low. You need at least MySQL 5.5.'); + } + + ($this->store)( + new MySqlConnection( + $pdo, + $this->dbConfig['database'], + $this->dbConfig['prefix'], + $this->dbConfig + ) + ); + } +} diff --git a/src/Install/Steps/CreateAdminUser.php b/src/Install/Steps/CreateAdminUser.php new file mode 100644 index 000000000..af51fa04d --- /dev/null +++ b/src/Install/Steps/CreateAdminUser.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Install\Steps; + +use Carbon\Carbon; +use Flarum\Group\Group; +use Flarum\Install\Step; +use Illuminate\Database\ConnectionInterface; +use Illuminate\Hashing\BcryptHasher; +use UnexpectedValueException; + +class CreateAdminUser implements Step +{ + /** + * @var ConnectionInterface + */ + private $database; + + /** + * @var array + */ + private $admin; + + public function __construct(ConnectionInterface $database, array $admin) + { + $this->database = $database; + $this->admin = $admin; + } + + public function getMessage() + { + return 'Creating admin user '.$this->admin['username']; + } + + public function run() + { + if ($this->admin['password'] !== $this->admin['password_confirmation']) { + throw new UnexpectedValueException('The password did not match its confirmation.'); + } + + $uid = $this->database->table('users')->insertGetId([ + 'username' => $this->admin['username'], + 'email' => $this->admin['email'], + 'password' => (new BcryptHasher)->make($this->admin['password']), + 'joined_at' => Carbon::now(), + 'is_email_confirmed' => 1, + ]); + + $this->database->table('group_user')->insert([ + 'user_id' => $uid, + 'group_id' => Group::ADMINISTRATOR_ID, + ]); + } +} diff --git a/src/Install/Steps/EnableBundledExtensions.php b/src/Install/Steps/EnableBundledExtensions.php new file mode 100644 index 000000000..f3f045b0a --- /dev/null +++ b/src/Install/Steps/EnableBundledExtensions.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Install\Steps; + +use Flarum\Database\DatabaseMigrationRepository; +use Flarum\Database\Migrator; +use Flarum\Extension\Extension; +use Flarum\Install\Step; +use Flarum\Settings\DatabaseSettingsRepository; +use Illuminate\Database\ConnectionInterface; +use Illuminate\Support\Arr; +use League\Flysystem\Adapter\Local; +use League\Flysystem\Filesystem; + +class EnableBundledExtensions implements Step +{ + /** + * @var ConnectionInterface + */ + private $database; + + /** + * @var string + */ + private $basePath; + + /** + * @var string + */ + private $assetPath; + + public function __construct(ConnectionInterface $database, $basePath, $assetPath) + { + $this->database = $database; + $this->basePath = $basePath; + $this->assetPath = $assetPath; + } + + public function getMessage() + { + return 'Enabling bundled extensions'; + } + + public function run() + { + $extensions = $this->loadExtensions(); + + foreach ($extensions as $extension) { + $extension->migrate($this->getMigrator()); + $extension->copyAssetsTo( + new Filesystem(new Local($this->assetPath)) + ); + } + + (new DatabaseSettingsRepository($this->database))->set( + 'extensions_enabled', + $extensions->keys()->toJson() + ); + } + + const DISABLED_EXTENSIONS = [ + 'flarum-akismet', + 'flarum-auth-facebook', + 'flarum-auth-github', + 'flarum-auth-twitter', + 'flarum-pusher', + ]; + + /** + * @return \Illuminate\Support\Collection + */ + private function loadExtensions() + { + $json = file_get_contents("$this->basePath/vendor/composer/installed.json"); + + return collect(json_decode($json, true)) + ->filter(function ($package) { + return Arr::get($package, 'type') == 'flarum-extension'; + })->filter(function ($package) { + return ! empty(Arr::get($package, 'name')); + })->map(function ($package) { + $extension = new Extension($this->basePath.'/vendor/'.Arr::get($package, 'name'), $package); + $extension->setVersion(Arr::get($package, 'version')); + + return $extension; + })->filter(function (Extension $extension) { + return ! in_array($extension->getId(), self::DISABLED_EXTENSIONS); + })->sortBy(function (Extension $extension) { + return $extension->composerJsonAttribute('extra.flarum-extension.title'); + })->mapWithKeys(function (Extension $extension) { + return [$extension->getId() => $extension]; + }); + } + + private function getMigrator() + { + return $this->migrator = $this->migrator ?? new Migrator( + new DatabaseMigrationRepository($this->database, 'migrations'), + $this->database, + new \Illuminate\Filesystem\Filesystem + ); + } +} diff --git a/src/Install/Steps/PublishAssets.php b/src/Install/Steps/PublishAssets.php new file mode 100644 index 000000000..7ec1d767a --- /dev/null +++ b/src/Install/Steps/PublishAssets.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Install\Steps; + +use Flarum\Install\Step; +use Illuminate\Filesystem\Filesystem; + +class PublishAssets implements Step +{ + /** + * @var string + */ + private $basePath; + + /** + * @var string + */ + private $assetPath; + + public function __construct($basePath, $assetPath) + { + $this->basePath = $basePath; + $this->assetPath = $assetPath; + } + + public function getMessage() + { + return 'Publishing all assets'; + } + + public function run() + { + (new Filesystem)->copyDirectory( + "$this->basePath/vendor/components/font-awesome/webfonts", + "$this->assetPath/fonts" + ); + } +} diff --git a/src/Install/Steps/RunMigrations.php b/src/Install/Steps/RunMigrations.php new file mode 100644 index 000000000..3bf2049ef --- /dev/null +++ b/src/Install/Steps/RunMigrations.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Install\Steps; + +use Flarum\Database\DatabaseMigrationRepository; +use Flarum\Database\Migrator; +use Flarum\Install\Step; +use Illuminate\Database\ConnectionInterface; +use Illuminate\Filesystem\Filesystem; + +class RunMigrations implements Step +{ + /** + * @var ConnectionInterface + */ + private $database; + + /** + * @var string + */ + private $path; + + public function __construct(ConnectionInterface $database, $path) + { + $this->database = $database; + $this->path = $path; + } + + public function getMessage() + { + return 'Running migrations'; + } + + public function run() + { + $migrator = $this->getMigrator(); + + $migrator->getRepository()->createRepository(); + $migrator->run($this->path); + } + + private function getMigrator() + { + $repository = new DatabaseMigrationRepository( + $this->database, 'migrations' + ); + $files = new Filesystem; + + return new Migrator($repository, $this->database, $files); + } +} diff --git a/src/Install/Steps/StoreConfig.php b/src/Install/Steps/StoreConfig.php new file mode 100644 index 000000000..f2c2a22f3 --- /dev/null +++ b/src/Install/Steps/StoreConfig.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Install\Steps; + +use Flarum\Install\ReversibleStep; +use Flarum\Install\Step; + +class StoreConfig implements Step, ReversibleStep +{ + private $config; + + private $configFile; + + public function __construct(array $config, $configFile) + { + $this->config = $config; + $this->configFile = $configFile; + } + + public function getMessage() + { + return 'Writing config file'; + } + + public function run() + { + file_put_contents( + $this->configFile, + 'config, true).';' + ); + } + + public function revert() + { + @unlink($this->configFile); + } +} diff --git a/src/Install/Steps/WriteSettings.php b/src/Install/Steps/WriteSettings.php new file mode 100644 index 000000000..657e838d9 --- /dev/null +++ b/src/Install/Steps/WriteSettings.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Install\Steps; + +use Flarum\Foundation\Application; +use Flarum\Install\Step; +use Flarum\Settings\DatabaseSettingsRepository; +use Illuminate\Database\ConnectionInterface; + +class WriteSettings implements Step +{ + /** + * @var ConnectionInterface + */ + private $database; + + /** + * @var array + */ + private $defaults; + + public function __construct(ConnectionInterface $database, array $defaults) + { + $this->database = $database; + $this->defaults = $defaults; + } + + public function getMessage() + { + return 'Writing default settings'; + } + + public function run() + { + $repo = new DatabaseSettingsRepository($this->database); + + $repo->set('version', Application::VERSION); + + foreach ($this->defaults as $key => $value) { + $repo->set($key, $value); + } + } +}