diff --git a/src/Extension/Extension.php b/src/Extension/Extension.php index a85792961c..a657c4e951 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 @@ public function hasAssets() 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 @@ public function hasMigrations() 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 45decbe948..2214183674 100644 --- a/src/Extension/ExtensionManager.php +++ b/src/Extension/ExtensionManager.php @@ -222,26 +222,16 @@ public function getAsset(Extension $extension, $path) * 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 @@ public function migrate(Extension $extension, $up = true) */ 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 70f300071f..62841faeab 100644 --- a/src/Install/Console/DefaultsDataProvider.php +++ b/src/Install/Console/DefaultsDataProvider.php @@ -14,13 +14,16 @@ 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 37cd346554..e429d62d89 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 DataProviderInterface - */ - protected $dataSource; - - /** - * @var Application + * @var Installation */ - protected $application; + protected $installation; /** - * @var Filesystem + * @var Factory */ - protected $filesystem; + protected $validator; /** - * @var ConnectionInterface - */ - protected $db; - - /** - * @var Migrator + * @var DataProviderInterface */ - protected $migrator; + protected $dataSource; /** - * @param Application $application - * @param Filesystem $filesystem + * @param Installation $installation + * @param Factory $validator */ - 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 @@ protected function fire() { $this->init(); - $prerequisites = $this->getPrerequisites(); + $prerequisites = $this->installation->prerequisites(); $prerequisites->check(); $errors = $prerequisites->getErrors(); @@ -124,245 +100,82 @@ protected function fire() 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(); - - $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' + $dbConfig = $this->dataSource->getDatabaseConfiguration(); + + $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'); + if ($validation->fails()) { + throw new Exception(implode("\n", + call_user_func_array('array_merge', + $validation->getMessageBag()->toArray()))); + } - $settings->set('version', $this->application->version()); + $admin = $this->dataSource->getAdminUser(); - foreach ($this->settings as $k => $v) { - $settings->set($k, $v); + if (strlen($admin['password']) < 8) { + throw new Exception('Password must be at least 8 characters.'); } - } - - protected function createAdminUser() - { - $admin = $this->adminUser; 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); + if (! filter_var($admin['email'], FILTER_VALIDATE_EMAIL)) { + throw new Exception('You must enter a valid email.'); + } - $extensions->enable($name); + if (! $admin['username'] || preg_match('/[^a-z0-9_-]/i', + $admin['username'])) { + throw new Exception('Username can only contain letters, numbers, underscores, and dashes.'); } - } - protected function publishAssets() - { - $this->filesystem->copyDirectory( - $this->application->basePath().'/vendor/components/font-awesome/webfonts', - $this->application->publicPath().'/assets/fonts' + $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() - { - 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)); + private function runPipeline(Pipeline $pipeline) + { + $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 a84df20506..eb10ef5ba3 100644 --- a/src/Install/Console/UserDataProvider.php +++ b/src/Install/Console/UserDataProvider.php @@ -43,13 +43,16 @@ public function getDatabaseConfiguration() } 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 e961109697..03b1e24490 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 @@ public function render(Request $request) { $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 aaa13e2775..835a6e312b 100644 --- a/src/Install/Controller/InstallController.php +++ b/src/Install/Controller/InstallController.php @@ -13,19 +13,18 @@ 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 @@ public function handle(Request $request): ResponseInterface { $input = $request->getParsedBody(); - $data = new DefaultsDataProvider; - $host = array_get($input, 'mysqlHost'); $port = '3306'; @@ -60,45 +57,58 @@ public function handle(Request $request): ResponseInterface 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 4a0c8ae039..1ea4fa07bc 100644 --- a/src/Install/InstallServiceProvider.php +++ b/src/Install/InstallServiceProvider.php @@ -14,11 +14,6 @@ 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 0000000000..0da25ca9f8 --- /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 3df6a1b865..287a513fe3 100644 --- a/src/Install/Installer.php +++ b/src/Install/Installer.php @@ -17,6 +17,8 @@ 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 @@ public function getRequestHandler() 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 0000000000..31c8cb1c90 --- /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 0000000000..b65de49936 --- /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 0000000000..6b0de2658b --- /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 0000000000..8fe1a3b7d0 --- /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 0000000000..bab1a160e0 --- /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 0000000000..1f8357b251 --- /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 0000000000..af51fa04d2 --- /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 0000000000..f3f045b0aa --- /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 0000000000..7ec1d767ae --- /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 0000000000..3bf2049ef3 --- /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 0000000000..f2c2a22f33 --- /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 0000000000..657e838d95 --- /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); + } + } +}