diff --git a/framework/core/migrations/2018_01_18_135100_change_posts_add_foreign_keys.php b/framework/core/migrations/2018_01_18_135100_change_posts_add_foreign_keys.php index 00ef23223..ffdaad0df 100644 --- a/framework/core/migrations/2018_01_18_135100_change_posts_add_foreign_keys.php +++ b/framework/core/migrations/2018_01_18_135100_change_posts_add_foreign_keys.php @@ -41,7 +41,6 @@ return [ 'down' => function (Builder $schema) { $schema->table('posts', function (Blueprint $table) { $table->dropForeign(['user_id']); - $table->dropForeign(['discussion_id']); $table->dropForeign(['edited_user_id']); $table->dropForeign(['hidden_user_id']); }); diff --git a/framework/core/migrations/2018_09_22_004100_change_registration_tokens_columns.php b/framework/core/migrations/2018_09_22_004100_change_registration_tokens_columns.php index 526899c84..6fc797fea 100644 --- a/framework/core/migrations/2018_09_22_004100_change_registration_tokens_columns.php +++ b/framework/core/migrations/2018_09_22_004100_change_registration_tokens_columns.php @@ -24,7 +24,7 @@ return [ }, 'down' => function (Builder $schema) { - $schema->table('auth_tokens', function (Blueprint $table) { + $schema->table('registration_tokens', function (Blueprint $table) { $table->dropColumn('provider', 'identifier', 'user_attributes'); $table->string('payload', 150)->change(); diff --git a/framework/core/src/Database/Migrator.php b/framework/core/src/Database/Migrator.php index 980abe2f6..ac61af7d7 100644 --- a/framework/core/src/Database/Migrator.php +++ b/framework/core/src/Database/Migrator.php @@ -146,7 +146,9 @@ class Migrator */ public function reset($path, Extension $extension = null) { - $migrations = array_reverse($this->repository->getRan($extension->getId())); + $migrations = array_reverse($this->repository->getRan( + $extension ? $extension->getId() : null + )); $count = count($migrations); diff --git a/framework/core/src/Extension/Extension.php b/framework/core/src/Extension/Extension.php index a85792961..a657c4e95 100644 --- a/framework/core/src/Extension/Extension.php +++ b/framework/core/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/framework/core/src/Extension/ExtensionManager.php b/framework/core/src/Extension/ExtensionManager.php index 45decbe94..221418367 100644 --- a/framework/core/src/Extension/ExtensionManager.php +++ b/framework/core/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/framework/core/src/Install/AdminUser.php b/framework/core/src/Install/AdminUser.php new file mode 100644 index 000000000..943191516 --- /dev/null +++ b/framework/core/src/Install/AdminUser.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Install; + +use Carbon\Carbon; +use Illuminate\Hashing\BcryptHasher; + +class AdminUser +{ + private $username; + private $password; + private $email; + + public function __construct($username, $password, $email) + { + $this->username = $username; + $this->password = $password; + $this->email = $email; + + $this->validate(); + } + + public function getUsername() + { + return $this->username; + } + + public function getAttributes(): array + { + return [ + 'username' => $this->username, + 'email' => $this->email, + 'password' => (new BcryptHasher)->make($this->password), + 'joined_at' => Carbon::now(), + 'is_email_confirmed' => 1, + ]; + } + + private function validate() + { + if (! filter_var($this->email, FILTER_VALIDATE_EMAIL)) { + throw new ValidationFailed('You must enter a valid email.'); + } + + if (! $this->username || preg_match('/[^a-z0-9_-]/i', $this->username)) { + throw new ValidationFailed('Username can only contain letters, numbers, underscores, and dashes.'); + } + } +} diff --git a/framework/core/src/Install/Console/DataProviderInterface.php b/framework/core/src/Install/Console/DataProviderInterface.php index 9976598e2..94816bd7b 100644 --- a/framework/core/src/Install/Console/DataProviderInterface.php +++ b/framework/core/src/Install/Console/DataProviderInterface.php @@ -11,15 +11,9 @@ namespace Flarum\Install\Console; +use Flarum\Install\Installation; + interface DataProviderInterface { - public function getDatabaseConfiguration(); - - public function getBaseUrl(); - - public function getAdminUser(); - - public function getSettings(); - - public function isDebugMode(): bool; + public function configure(Installation $installation): Installation; } diff --git a/framework/core/src/Install/Console/DefaultsDataProvider.php b/framework/core/src/Install/Console/DefaultsDataProvider.php deleted file mode 100644 index 70f300071..000000000 --- a/framework/core/src/Install/Console/DefaultsDataProvider.php +++ /dev/null @@ -1,111 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flarum\Install\Console; - -class DefaultsDataProvider implements DataProviderInterface -{ - protected $databaseConfiguration = [ - 'driver' => 'mysql', - 'host' => 'localhost', - 'database' => 'flarum', - 'username' => 'root', - 'password' => '', - 'prefix' => '', - 'port' => '3306', - ]; - - protected $debug = false; - - protected $baseUrl = 'http://flarum.local'; - - protected $adminUser = [ - 'username' => 'admin', - 'password' => 'password', - 'password_confirmation' => 'password', - 'email' => 'admin@example.com', - ]; - - protected $settings = [ - 'allow_post_editing' => 'reply', - 'allow_renaming' => '10', - 'allow_sign_up' => '1', - 'custom_less' => '', - 'default_locale' => 'en', - 'default_route' => '/all', - 'extensions_enabled' => '[]', - 'forum_title' => 'Development Forum', - 'forum_description' => '', - 'mail_driver' => 'mail', - 'mail_from' => 'noreply@flarum.dev', - '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 Development Forum', - ]; - - public function getDatabaseConfiguration() - { - return $this->databaseConfiguration; - } - - public function setDatabaseConfiguration(array $databaseConfiguration) - { - $this->databaseConfiguration = $databaseConfiguration; - } - - public function getBaseUrl() - { - return $this->baseUrl; - } - - public function setBaseUrl($baseUrl) - { - $this->baseUrl = $baseUrl; - } - - public function getAdminUser() - { - return $this->adminUser; - } - - public function setAdminUser(array $adminUser) - { - $this->adminUser = $adminUser; - } - - public function getSettings() - { - return $this->settings; - } - - public function setSettings(array $settings) - { - $this->settings = $settings; - } - - public function setSetting($key, $value) - { - $this->settings[$key] = $value; - } - - public function isDebugMode(): bool - { - return $this->debug; - } - - public function setDebugMode(bool $debug = true) - { - $this->debug = $debug; - } -} diff --git a/framework/core/src/Install/Console/FileDataProvider.php b/framework/core/src/Install/Console/FileDataProvider.php index 0eb8886f0..07626bfa4 100644 --- a/framework/core/src/Install/Console/FileDataProvider.php +++ b/framework/core/src/Install/Console/FileDataProvider.php @@ -12,12 +12,14 @@ namespace Flarum\Install\Console; use Exception; +use Flarum\Install\AdminUser; +use Flarum\Install\DatabaseConfig; +use Flarum\Install\Installation; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Yaml\Yaml; class FileDataProvider implements DataProviderInterface { - protected $default; protected $debug = false; protected $baseUrl = null; protected $databaseConfiguration = []; @@ -26,9 +28,6 @@ class FileDataProvider implements DataProviderInterface public function __construct(InputInterface $input) { - // Get default configuration - $this->default = new DefaultsDataProvider(); - // Get configuration file path $configurationFile = $input->getOption('file'); @@ -55,28 +54,35 @@ class FileDataProvider implements DataProviderInterface } } - public function getDatabaseConfiguration() + public function configure(Installation $installation): Installation { - return $this->databaseConfiguration + $this->default->getDatabaseConfiguration(); + return $installation + ->debugMode($this->debug) + ->baseUrl($this->baseUrl ?? 'http://flarum.local') + ->databaseConfig($this->getDatabaseConfiguration()) + ->adminUser($this->getAdminUser()) + ->settings($this->settings); } - public function getBaseUrl() + private function getDatabaseConfiguration(): DatabaseConfig { - return (! is_null($this->baseUrl)) ? $this->baseUrl : $this->default->getBaseUrl(); + return new DatabaseConfig( + $this->databaseConfiguration['driver'] ?? 'mysql', + $this->databaseConfiguration['host'] ?? 'localhost', + $this->databaseConfiguration['port'] ?? 3306, + $this->databaseConfiguration['database'] ?? 'flarum', + $this->databaseConfiguration['username'] ?? 'root', + $this->databaseConfiguration['password'] ?? '', + $this->databaseConfiguration['prefix'] ?? '' + ); } - public function getAdminUser() + private function getAdminUser(): AdminUser { - return $this->adminUser + $this->default->getAdminUser(); - } - - public function getSettings() - { - return $this->settings + $this->default->getSettings(); - } - - public function isDebugMode(): bool - { - return $this->debug; + return new AdminUser( + $this->adminUser['username'] ?? 'admin', + $this->adminUser['password'] ?? 'password', + $this->adminUser['email'] ?? 'admin@example.com' + ); } } diff --git a/framework/core/src/Install/Console/InstallCommand.php b/framework/core/src/Install/Console/InstallCommand.php index 37cd34655..6356cf620 100644 --- a/framework/core/src/Install/Console/InstallCommand.php +++ b/framework/core/src/Install/Console/InstallCommand.php @@ -11,65 +11,32 @@ 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 Symfony\Component\Console\Input\InputOption; class InstallCommand extends AbstractCommand { + /** + * @var Installation + */ + protected $installation; + /** * @var DataProviderInterface */ protected $dataSource; /** - * @var Application + * @param Installation $installation */ - 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) { - $this->application = $application; + $this->installation = $installation; parent::__construct(); - $this->filesystem = $filesystem; } protected function configure() @@ -77,12 +44,6 @@ class InstallCommand extends AbstractCommand $this ->setName('install') ->setDescription("Run Flarum's installation migration and seeds") - ->addOption( - 'defaults', - 'd', - InputOption::VALUE_NONE, - 'Create default settings and user' - ) ->addOption( 'file', 'f', @@ -104,274 +65,64 @@ class InstallCommand extends AbstractCommand { $this->init(); - $prerequisites = $this->getPrerequisites(); - $prerequisites->check(); - $errors = $prerequisites->getErrors(); + $problems = $this->installation->prerequisites()->problems(); - if (empty($errors)) { + if ($problems->isEmpty()) { $this->info('Installing Flarum...'); $this->install(); $this->info('DONE.'); } else { - $this->output->writeln( - 'Please fix the following errors before we can continue with the installation.' - ); - $this->showErrors($errors); + $this->showProblems($problems); } } 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('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(); + $pipeline = $this->dataSource->configure( + $this->installation->configPath($this->input->getOption('config')) + )->build(); - $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; - } + $this->runPipeline($pipeline); } - protected function storeConfiguration(bool $debugMode) + private function runPipeline(Pipeline $pipeline) { - $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' - ); - $files = $this->application->make('files'); - - $this->migrator = new Migrator($repository, $this->db, $files); - - $this->info('Writing config'); - - file_put_contents( - $this->getConfigFile(), - '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 runMigrations() + protected function showProblems($problems) { - $this->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); - } - } - - 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') + $this->output->writeln( + 'Please fix the following problems before we can continue with the installation.' ); - $disabled = [ - 'flarum-akismet', - 'flarum-auth-facebook', - 'flarum-auth-github', - 'flarum-auth-twitter', - 'flarum-pusher', - ]; + foreach ($problems as $problem) { + $this->info($problem['message']); - foreach ($extensions->getExtensions() as $name => $extension) { - if (in_array($name, $disabled)) { - continue; - } - - $this->info('Enabling extension: '.$name); - - $extensions->enable($name); - } - } - - protected function publishAssets() - { - $this->filesystem->copyDirectory( - $this->application->basePath().'/vendor/components/font-awesome/webfonts', - $this->application->publicPath().'/assets/fonts' - ); - } - - 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)); - } - - protected function showErrors($errors) - { - foreach ($errors as $error) { - $this->info($error['message']); - - if (isset($error['detail'])) { - $this->output->writeln(''.$error['detail'].''); + if (isset($problem['detail'])) { + $this->output->writeln(''.$problem['detail'].''); } } } diff --git a/framework/core/src/Install/Console/UserDataProvider.php b/framework/core/src/Install/Console/UserDataProvider.php index a84df2050..d1c4caa20 100644 --- a/framework/core/src/Install/Console/UserDataProvider.php +++ b/framework/core/src/Install/Console/UserDataProvider.php @@ -11,6 +11,9 @@ namespace Flarum\Install\Console; +use Flarum\Install\AdminUser; +use Flarum\Install\DatabaseConfig; +use Flarum\Install\Installation; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -33,75 +36,91 @@ class UserDataProvider implements DataProviderInterface $this->questionHelper = $questionHelper; } - public function getDatabaseConfiguration() + public function configure(Installation $installation): Installation + { + return $installation + ->debugMode(false) + ->baseUrl($this->getBaseUrl()) + ->databaseConfig($this->getDatabaseConfiguration()) + ->adminUser($this->getAdminUser()) + ->settings($this->getSettings()); + } + + private function getDatabaseConfiguration(): DatabaseConfig { $host = $this->ask('Database host:'); - $port = '3306'; + $port = 3306; if (str_contains($host, ':')) { list($host, $port) = explode(':', $host, 2); } - 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:'), - ]; + return new DatabaseConfig( + 'mysql', + $host, + intval($port), + $this->ask('Database name:'), + $this->ask('Database user:'), + $this->secret('Database password:'), + $this->ask('Prefix:') + ); } - public function getBaseUrl() + private function getBaseUrl() { return $this->baseUrl = rtrim($this->ask('Base URL:'), '/'); } - public function getAdminUser() + private function getAdminUser(): AdminUser { - return [ - 'username' => $this->ask('Admin username:'), - 'password' => $this->secret('Admin password:'), - 'password_confirmation' => $this->secret('Admin password (confirmation):'), - 'email' => $this->ask('Admin email address:'), - ]; + return new AdminUser( + $this->ask('Admin username:'), + $this->askForAdminPassword(), + $this->ask('Admin email address:') + ); } - public function getSettings() + private function askForAdminPassword() + { + while (true) { + $password = $this->secret('Admin password:'); + + if (strlen($password) < 8) { + $this->validationError('Password must be at least 8 characters.'); + continue; + } + + $confirmation = $this->secret('Admin password (confirmation):'); + + if ($password !== $confirmation) { + $this->validationError('The password did not match its confirmation.'); + continue; + } + + return $password; + } + } + + private function getSettings() { $title = $this->ask('Forum title:'); $baseUrl = $this->baseUrl ?: 'http://localhost'; return [ - 'allow_post_editing' => 'reply', - 'allow_renaming' => '10', - 'allow_sign_up' => '1', - 'custom_less' => '', - 'default_locale' => 'en', - 'default_route' => '/all', - 'extensions_enabled' => '[]', 'forum_title' => $title, - '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 '.$title, ]; } - protected function ask($question, $default = null) + private function ask($question, $default = null) { $question = new Question("$question ", $default); return $this->questionHelper->ask($this->input, $this->output, $question); } - protected function secret($question) + private function secret($question) { $question = new Question("$question "); @@ -110,8 +129,9 @@ class UserDataProvider implements DataProviderInterface return $this->questionHelper->ask($this->input, $this->output, $question); } - public function isDebugMode(): bool + private function validationError($message) { - return false; + $this->output->writeln("$message"); + $this->output->writeln('Please try again.'); } } diff --git a/framework/core/src/Install/Controller/IndexController.php b/framework/core/src/Install/Controller/IndexController.php index e96110969..3d66789d2 100644 --- a/framework/core/src/Install/Controller/IndexController.php +++ b/framework/core/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,13 +46,12 @@ class IndexController extends AbstractHtmlController { $view = $this->view->make('flarum.install::app')->with('title', 'Install Flarum'); - $this->prerequisite->check(); - $errors = $this->prerequisite->getErrors(); + $problems = $this->installation->prerequisites()->problems(); - if (count($errors)) { - $view->with('content', $this->view->make('flarum.install::errors')->with('errors', $errors)); - } else { + if ($problems->isEmpty()) { $view->with('content', $this->view->make('flarum.install::install')); + } else { + $view->with('content', $this->view->make('flarum.install::problems')->with('problems', $problems)); } return $view; diff --git a/framework/core/src/Install/Controller/InstallController.php b/framework/core/src/Install/Controller/InstallController.php index aaa13e277..cbefc3849 100644 --- a/framework/core/src/Install/Controller/InstallController.php +++ b/framework/core/src/Install/Controller/InstallController.php @@ -11,21 +11,23 @@ namespace Flarum\Install\Controller; -use Exception; use Flarum\Http\SessionAuthenticator; -use Flarum\Install\Console\DefaultsDataProvider; -use Flarum\Install\Console\InstallCommand; +use Flarum\Install\AdminUser; +use Flarum\Install\DatabaseConfig; +use Flarum\Install\Installation; +use Flarum\Install\StepFailed; +use Flarum\Install\ValidationFailed; 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 +36,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; } @@ -50,55 +52,78 @@ class InstallController implements RequestHandlerInterface public function handle(Request $request): ResponseInterface { $input = $request->getParsedBody(); - - $data = new DefaultsDataProvider; - - $host = array_get($input, 'mysqlHost'); - $port = '3306'; - - if (str_contains($host, ':')) { - 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); try { - $this->command->run($input, $output); - } catch (Exception $e) { - return new HtmlResponse($e->getMessage(), 500); + $pipeline = $this->installation + ->baseUrl($baseUrl) + ->databaseConfig($this->makeDatabaseConfig($input)) + ->adminUser($this->makeAdminUser($input)) + ->settings([ + 'forum_title' => array_get($input, 'forumTitle'), + 'mail_from' => 'noreply@'.preg_replace('/^www\./i', '', parse_url($baseUrl, PHP_URL_HOST)), + 'welcome_title' => 'Welcome to '.array_get($input, 'forumTitle'), + ]) + ->build(); + } catch (ValidationFailed $e) { + return new Response\HtmlResponse($e->getMessage(), 500); + } + + try { + $pipeline->run(); + } catch (StepFailed $e) { + return new Response\HtmlResponse($e->getPrevious()->getMessage(), 500); } $session = $request->getAttribute('session'); $this->authenticator->logIn($session, 1); - return new Response($body); + return new Response\EmptyResponse; + } + + private function makeDatabaseConfig(array $input): DatabaseConfig + { + $host = array_get($input, 'mysqlHost'); + $port = 3306; + + if (str_contains($host, ':')) { + list($host, $port) = explode(':', $host, 2); + } + + return new DatabaseConfig( + 'mysql', + $host, + intval($port), + array_get($input, 'mysqlDatabase'), + array_get($input, 'mysqlUsername'), + array_get($input, 'mysqlPassword'), + array_get($input, 'tablePrefix') + ); + } + + /** + * @param array $input + * @return AdminUser + * @throws ValidationFailed + */ + private function makeAdminUser(array $input): AdminUser + { + return new AdminUser( + array_get($input, 'adminUsername'), + $this->getConfirmedAdminPassword($input), + array_get($input, 'adminEmail') + ); + } + + private function getConfirmedAdminPassword(array $input): string + { + $password = array_get($input, 'adminPassword'); + $confirmation = array_get($input, 'adminPasswordConfirmation'); + + if ($password !== $confirmation) { + throw new ValidationFailed('The admin password did not match its confirmation.'); + } + + return $password; } } diff --git a/framework/core/src/Install/DatabaseConfig.php b/framework/core/src/Install/DatabaseConfig.php new file mode 100644 index 000000000..60ed3ea5d --- /dev/null +++ b/framework/core/src/Install/DatabaseConfig.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Install; + +use Illuminate\Contracts\Support\Arrayable; + +class DatabaseConfig implements Arrayable +{ + private $driver; + private $host; + private $port; + private $database; + private $username; + private $password; + private $prefix; + + public function __construct($driver, $host, $port, $database, $username, $password, $prefix) + { + $this->driver = $driver; + $this->host = $host; + $this->port = $port; + $this->database = $database; + $this->username = $username; + $this->password = $password; + $this->prefix = $prefix; + + $this->validate(); + } + + public function toArray() + { + return [ + 'driver' => $this->driver, + 'host' => $this->host, + 'port' => $this->port, + 'database' => $this->database, + 'username' => $this->username, + 'password' => $this->password, + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => $this->prefix, + 'strict' => false, + 'engine' => 'InnoDB', + ]; + } + + private function validate() + { + if (empty($this->driver)) { + throw new ValidationFailed('Please specify a database driver.'); + } + + if ($this->driver !== 'mysql') { + throw new ValidationFailed('Currently, only MySQL/MariaDB is supported.'); + } + + if (empty($this->host)) { + throw new ValidationFailed('Please specify the hostname of your database server.'); + } + + if (! is_int($this->port) || $this->port < 1 || $this->port > 65535) { + throw new ValidationFailed('Please provide a valid port number between 1 and 65535.'); + } + + if (empty($this->database)) { + throw new ValidationFailed('Please specify the database name.'); + } + + if (! is_string($this->database)) { + throw new ValidationFailed('The database name must be a non-empty string.'); + } + + if (empty($this->username)) { + throw new ValidationFailed('Please specify the username for accessing the database.'); + } + + if (! is_string($this->database)) { + throw new ValidationFailed('The username must be a non-empty string.'); + } + + if (! empty($this->prefix)) { + if (! preg_match('/^[\pL\pM\pN_-]+$/u', $this->prefix)) { + throw new ValidationFailed('The prefix may only contain characters, dashes and underscores.'); + } + + if (strlen($this->prefix) > 10) { + throw new ValidationFailed('The prefix should be no longer than 10 characters.'); + } + } + } +} diff --git a/framework/core/src/Install/InstallServiceProvider.php b/framework/core/src/Install/InstallServiceProvider.php index 4a0c8ae03..1ea4fa07b 100644 --- a/framework/core/src/Install/InstallServiceProvider.php +++ b/framework/core/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/framework/core/src/Install/Installation.php b/framework/core/src/Install/Installation.php new file mode 100644 index 000000000..074d5d135 --- /dev/null +++ b/framework/core/src/Install/Installation.php @@ -0,0 +1,164 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Flarum\Install; + +class Installation +{ + private $basePath; + private $publicPath; + private $storagePath; + + private $configPath; + private $debug = false; + private $baseUrl; + private $customSettings = []; + + /** @var DatabaseConfig */ + private $dbConfig; + + /** @var AdminUser */ + private $adminUser; + + // A few instance variables to persist objects between steps. + // Could also be local variables in build(), but this way + // access in closures is easier. :) + + /** @var \Illuminate\Database\ConnectionInterface */ + private $db; + + 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(DatabaseConfig $dbConfig) + { + $this->dbConfig = $dbConfig; + + return $this; + } + + public function baseUrl($baseUrl) + { + $this->baseUrl = $baseUrl; + + return $this; + } + + public function settings($settings) + { + $this->customSettings = $settings; + + return $this; + } + + public function adminUser(AdminUser $admin) + { + $this->adminUser = $admin; + + return $this; + } + + public function prerequisites(): Prerequisite\PrerequisiteInterface + { + return new Prerequisite\Composite( + new Prerequisite\PhpVersion('7.1.0'), + new Prerequisite\PhpExtensions([ + 'dom', + 'gd', + 'json', + 'mbstring', + 'openssl', + 'pdo_mysql', + 'tokenizer', + ]), + new Prerequisite\WritablePaths([ + $this->basePath, + $this->getAssetPath(), + $this->storagePath, + ]) + ); + } + + public function build(): Pipeline + { + $pipeline = new Pipeline; + + $pipeline->pipe(function () { + return new Steps\ConnectToDatabase( + $this->dbConfig, + function ($connection) { + $this->db = $connection; + } + ); + }); + + $pipeline->pipe(function () { + return new Steps\StoreConfig( + $this->debug, $this->dbConfig, $this->baseUrl, $this->getConfigPath() + ); + }); + + $pipeline->pipe(function () { + return new Steps\RunMigrations($this->db, $this->getMigrationPath()); + }); + + $pipeline->pipe(function () { + return new Steps\WriteSettings($this->db, $this->customSettings); + }); + + $pipeline->pipe(function () { + return new Steps\CreateAdminUser($this->db, $this->adminUser); + }); + + $pipeline->pipe(function () { + return new Steps\PublishAssets($this->basePath, $this->getAssetPath()); + }); + + $pipeline->pipe(function () { + return new Steps\EnableBundledExtensions($this->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/framework/core/src/Install/Installer.php b/framework/core/src/Install/Installer.php index 3df6a1b86..287a513fe 100644 --- a/framework/core/src/Install/Installer.php +++ b/framework/core/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/framework/core/src/Install/Pipeline.php b/framework/core/src/Install/Pipeline.php new file mode 100644 index 000000000..31c8cb1c9 --- /dev/null +++ b/framework/core/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/framework/core/src/Install/Prerequisite/AbstractPrerequisite.php b/framework/core/src/Install/Prerequisite/AbstractPrerequisite.php deleted file mode 100644 index 06c6210b1..000000000 --- a/framework/core/src/Install/Prerequisite/AbstractPrerequisite.php +++ /dev/null @@ -1,24 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flarum\Install\Prerequisite; - -abstract class AbstractPrerequisite implements PrerequisiteInterface -{ - protected $errors = []; - - abstract public function check(); - - public function getErrors() - { - return $this->errors; - } -} diff --git a/framework/core/src/Install/Prerequisite/Composite.php b/framework/core/src/Install/Prerequisite/Composite.php index ed0fb77e4..dcf380f1c 100644 --- a/framework/core/src/Install/Prerequisite/Composite.php +++ b/framework/core/src/Install/Prerequisite/Composite.php @@ -11,6 +11,8 @@ namespace Flarum\Install\Prerequisite; +use Illuminate\Support\Collection; + class Composite implements PrerequisiteInterface { /** @@ -25,21 +27,14 @@ class Composite implements PrerequisiteInterface } } - public function check() + public function problems(): Collection { return array_reduce( $this->prerequisites, - function ($previous, PrerequisiteInterface $prerequisite) { - return $prerequisite->check() && $previous; + function (Collection $errors, PrerequisiteInterface $condition) { + return $errors->concat($condition->problems()); }, - true + new Collection ); } - - public function getErrors() - { - return collect($this->prerequisites)->map(function (PrerequisiteInterface $prerequisite) { - return $prerequisite->getErrors(); - })->reduce('array_merge', []); - } } diff --git a/framework/core/src/Install/Prerequisite/PhpExtensions.php b/framework/core/src/Install/Prerequisite/PhpExtensions.php index 434eff927..89ed4ac7b 100644 --- a/framework/core/src/Install/Prerequisite/PhpExtensions.php +++ b/framework/core/src/Install/Prerequisite/PhpExtensions.php @@ -11,7 +11,9 @@ namespace Flarum\Install\Prerequisite; -class PhpExtensions extends AbstractPrerequisite +use Illuminate\Support\Collection; + +class PhpExtensions implements PrerequisiteInterface { protected $extensions; @@ -20,14 +22,15 @@ class PhpExtensions extends AbstractPrerequisite $this->extensions = $extensions; } - public function check() + public function problems(): Collection { - foreach ($this->extensions as $extension) { - if (! extension_loaded($extension)) { - $this->errors[] = [ + return (new Collection($this->extensions)) + ->reject(function ($extension) { + return extension_loaded($extension); + })->map(function ($extension) { + return [ 'message' => "The PHP extension '$extension' is required.", ]; - } - } + }); } } diff --git a/framework/core/src/Install/Prerequisite/PhpVersion.php b/framework/core/src/Install/Prerequisite/PhpVersion.php index b2960d372..3257b27c9 100644 --- a/framework/core/src/Install/Prerequisite/PhpVersion.php +++ b/framework/core/src/Install/Prerequisite/PhpVersion.php @@ -11,7 +11,9 @@ namespace Flarum\Install\Prerequisite; -class PhpVersion extends AbstractPrerequisite +use Illuminate\Support\Collection; + +class PhpVersion implements PrerequisiteInterface { protected $minVersion; @@ -20,13 +22,17 @@ class PhpVersion extends AbstractPrerequisite $this->minVersion = $minVersion; } - public function check() + public function problems(): Collection { + $collection = new Collection; + if (version_compare(PHP_VERSION, $this->minVersion, '<')) { - $this->errors[] = [ + $collection->push([ 'message' => "PHP $this->minVersion is required.", - 'detail' => 'You are running version '.PHP_VERSION.'. Talk to your hosting provider about upgrading to the latest PHP version.', - ]; + 'detail' => 'You are running version '.PHP_VERSION.'. You might want to talk to your system administrator about upgrading to the latest PHP version.', + ]); } + + return $collection; } } diff --git a/framework/core/src/Install/Prerequisite/PrerequisiteInterface.php b/framework/core/src/Install/Prerequisite/PrerequisiteInterface.php index 115e9f808..9b7907883 100644 --- a/framework/core/src/Install/Prerequisite/PrerequisiteInterface.php +++ b/framework/core/src/Install/Prerequisite/PrerequisiteInterface.php @@ -11,9 +11,18 @@ namespace Flarum\Install\Prerequisite; +use Illuminate\Support\Collection; + interface PrerequisiteInterface { - public function check(); - - public function getErrors(); + /** + * Verify that this prerequisite is fulfilled. + * + * If everything is okay, this method should return an empty Collection + * instance. When problems are detected, it should return a Collection of + * arrays, each having at least a "message" and optionally a "detail" key. + * + * @return Collection + */ + public function problems(): Collection; } diff --git a/framework/core/src/Install/Prerequisite/WritablePaths.php b/framework/core/src/Install/Prerequisite/WritablePaths.php index 1f356ee6a..0d5e8e7c5 100644 --- a/framework/core/src/Install/Prerequisite/WritablePaths.php +++ b/framework/core/src/Install/Prerequisite/WritablePaths.php @@ -11,7 +11,9 @@ namespace Flarum\Install\Prerequisite; -class WritablePaths extends AbstractPrerequisite +use Illuminate\Support\Collection; + +class WritablePaths implements PrerequisiteInterface { protected $paths; @@ -20,21 +22,36 @@ class WritablePaths extends AbstractPrerequisite $this->paths = $paths; } - public function check() + public function problems(): Collection { - foreach ($this->paths as $path) { - if (! file_exists($path)) { - $this->errors[] = [ + return $this->getMissingPaths() + ->concat($this->getNonWritablePaths()); + } + + private function getMissingPaths(): Collection + { + return (new Collection($this->paths)) + ->reject(function ($path) { + return file_exists($path); + })->map(function ($path) { + return [ 'message' => 'The '.$this->getAbsolutePath($path).' directory doesn\'t exist', 'detail' => 'This directory is necessary for the installation. Please create the folder.', ]; - } elseif (! is_writable($path)) { - $this->errors[] = [ + }); + } + + private function getNonWritablePaths(): Collection + { + return (new Collection($this->paths)) + ->filter(function ($path) { + return file_exists($path) && ! is_writable($path); + })->map(function ($path) { + return [ 'message' => 'The '.$this->getAbsolutePath($path).' directory is not writable.', 'detail' => 'Please chmod this directory'.($path !== public_path() ? ' and its contents' : '').' to 0775.' ]; - } - } + }); } private function getAbsolutePath($path) diff --git a/framework/core/src/Install/ReversibleStep.php b/framework/core/src/Install/ReversibleStep.php new file mode 100644 index 000000000..b65de4993 --- /dev/null +++ b/framework/core/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/framework/core/src/Install/Step.php b/framework/core/src/Install/Step.php new file mode 100644 index 000000000..6b0de2658 --- /dev/null +++ b/framework/core/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/framework/core/src/Install/StepFailed.php b/framework/core/src/Install/StepFailed.php new file mode 100644 index 000000000..8fe1a3b7d --- /dev/null +++ b/framework/core/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/framework/core/src/Install/Steps/ConnectToDatabase.php b/framework/core/src/Install/Steps/ConnectToDatabase.php new file mode 100644 index 000000000..51696731f --- /dev/null +++ b/framework/core/src/Install/Steps/ConnectToDatabase.php @@ -0,0 +1,62 @@ + + * + * 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\DatabaseConfig; +use Flarum\Install\Step; +use Illuminate\Database\Connectors\MySqlConnector; +use Illuminate\Database\MySqlConnection; +use RangeException; + +class ConnectToDatabase implements Step +{ + private $dbConfig; + private $store; + + public function __construct(DatabaseConfig $dbConfig, callable $store) + { + $this->dbConfig = $dbConfig; + $this->store = $store; + } + + public function getMessage() + { + return 'Connecting to database'; + } + + public function run() + { + $config = $this->dbConfig->toArray(); + $pdo = (new MySqlConnector)->connect($config); + + $version = $pdo->query('SELECT VERSION()')->fetchColumn(); + + if (str_contains($version, 'MariaDB')) { + if (version_compare($version, '10.0.5', '<')) { + throw new RangeException('MariaDB version too low. You need at least MariaDB 10.0.5'); + } + } else { + if (version_compare($version, '5.6.0', '<')) { + throw new RangeException('MySQL version too low. You need at least MySQL 5.6.'); + } + } + + ($this->store)( + new MySqlConnection( + $pdo, + $config['database'], + $config['prefix'], + $config + ) + ); + } +} diff --git a/framework/core/src/Install/Steps/CreateAdminUser.php b/framework/core/src/Install/Steps/CreateAdminUser.php new file mode 100644 index 000000000..b058c27cf --- /dev/null +++ b/framework/core/src/Install/Steps/CreateAdminUser.php @@ -0,0 +1,53 @@ + + * + * 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\Group\Group; +use Flarum\Install\AdminUser; +use Flarum\Install\Step; +use Illuminate\Database\ConnectionInterface; + +class CreateAdminUser implements Step +{ + /** + * @var ConnectionInterface + */ + private $database; + + /** + * @var AdminUser + */ + private $admin; + + public function __construct(ConnectionInterface $database, AdminUser $admin) + { + $this->database = $database; + $this->admin = $admin; + } + + public function getMessage() + { + return 'Creating admin user '.$this->admin->getUsername(); + } + + public function run() + { + $uid = $this->database->table('users')->insertGetId( + $this->admin->getAttributes() + ); + + $this->database->table('group_user')->insert([ + 'user_id' => $uid, + 'group_id' => Group::ADMINISTRATOR_ID, + ]); + } +} diff --git a/framework/core/src/Install/Steps/EnableBundledExtensions.php b/framework/core/src/Install/Steps/EnableBundledExtensions.php new file mode 100644 index 000000000..b887339e5 --- /dev/null +++ b/framework/core/src/Install/Steps/EnableBundledExtensions.php @@ -0,0 +1,122 @@ + + * + * 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 Illuminate\Support\Collection; +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 EXTENSION_WHITELIST = [ + 'flarum-approval', + 'flarum-bbcode', + 'flarum-emoji', + 'flarum-lang-english', + 'flarum-flags', + 'flarum-likes', + 'flarum-lock', + 'flarum-markdown', + 'flarum-mentions', + 'flarum-statistics', + 'flarum-sticky', + 'flarum-subscriptions', + 'flarum-suspend', + 'flarum-tags', + ]; + + /** + * @return \Illuminate\Support\Collection + */ + private function loadExtensions() + { + $json = file_get_contents("$this->basePath/vendor/composer/installed.json"); + + return (new Collection(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::EXTENSION_WHITELIST); + })->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/framework/core/src/Install/Steps/PublishAssets.php b/framework/core/src/Install/Steps/PublishAssets.php new file mode 100644 index 000000000..b8f20868b --- /dev/null +++ b/framework/core/src/Install/Steps/PublishAssets.php @@ -0,0 +1,58 @@ + + * + * 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; +use Illuminate\Filesystem\Filesystem; + +class PublishAssets implements Step, ReversibleStep +{ + /** + * @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->targetPath() + ); + } + + public function revert() + { + (new Filesystem)->deleteDirectory($this->targetPath()); + } + + private function targetPath() + { + return "$this->assetPath/fonts"; + } +} diff --git a/framework/core/src/Install/Steps/RunMigrations.php b/framework/core/src/Install/Steps/RunMigrations.php new file mode 100644 index 000000000..3bf2049ef --- /dev/null +++ b/framework/core/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/framework/core/src/Install/Steps/StoreConfig.php b/framework/core/src/Install/Steps/StoreConfig.php new file mode 100644 index 000000000..757f3e376 --- /dev/null +++ b/framework/core/src/Install/Steps/StoreConfig.php @@ -0,0 +1,72 @@ + + * + * 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\DatabaseConfig; +use Flarum\Install\ReversibleStep; +use Flarum\Install\Step; + +class StoreConfig implements Step, ReversibleStep +{ + private $debugMode; + + private $dbConfig; + + private $baseUrl; + + private $configFile; + + public function __construct($debugMode, DatabaseConfig $dbConfig, $baseUrl, $configFile) + { + $this->debugMode = $debugMode; + $this->dbConfig = $dbConfig; + $this->baseUrl = $baseUrl; + + $this->configFile = $configFile; + } + + public function getMessage() + { + return 'Writing config file'; + } + + public function run() + { + file_put_contents( + $this->configFile, + 'buildConfig(), true).';' + ); + } + + public function revert() + { + @unlink($this->configFile); + } + + private function buildConfig() + { + return [ + 'debug' => $this->debugMode, + 'database' => $this->dbConfig->toArray(), + 'url' => $this->baseUrl, + 'paths' => $this->getPathsConfig(), + ]; + } + + private function getPathsConfig() + { + return [ + 'api' => 'api', + 'admin' => 'admin', + ]; + } +} diff --git a/framework/core/src/Install/Steps/WriteSettings.php b/framework/core/src/Install/Steps/WriteSettings.php new file mode 100644 index 000000000..366fc3696 --- /dev/null +++ b/framework/core/src/Install/Steps/WriteSettings.php @@ -0,0 +1,80 @@ + + * + * 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 $custom; + + public function __construct(ConnectionInterface $database, array $custom) + { + $this->database = $database; + $this->custom = $custom; + } + + public function getMessage() + { + return 'Writing default settings'; + } + + public function run() + { + $repo = new DatabaseSettingsRepository($this->database); + + $repo->set('version', Application::VERSION); + + foreach ($this->getSettings() as $key => $value) { + $repo->set($key, $value); + } + } + + private function getSettings() + { + return $this->custom + $this->getDefaults(); + } + + private function getDefaults() + { + return [ + 'allow_post_editing' => 'reply', + 'allow_renaming' => '10', + 'allow_sign_up' => '1', + 'custom_less' => '', + 'default_locale' => 'en', + 'default_route' => '/all', + 'extensions_enabled' => '[]', + 'forum_title' => 'A new Flarum forum', + 'forum_description' => '', + 'mail_driver' => 'mail', + 'mail_from' => 'noreply@localhost', + '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 Flarum', + ]; + } +} diff --git a/framework/core/src/Install/ValidationFailed.php b/framework/core/src/Install/ValidationFailed.php new file mode 100644 index 000000000..d79206637 --- /dev/null +++ b/framework/core/src/Install/ValidationFailed.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 ValidationFailed extends Exception +{ +} diff --git a/framework/core/tests/Install/DefaultInstallationCommandTest.php b/framework/core/tests/Install/DefaultInstallationCommandTest.php deleted file mode 100644 index 0d2fee829..000000000 --- a/framework/core/tests/Install/DefaultInstallationCommandTest.php +++ /dev/null @@ -1,59 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Flarum\Tests\Install; - -use Flarum\Install\Console\InstallCommand; -use Flarum\Tests\Test\TestCase; -use Illuminate\Database\Connectors\ConnectionFactory; -use Symfony\Component\Console\Input\StringInput; -use Symfony\Component\Console\Output\StreamOutput; - -class DefaultInstallationCommandTest extends TestCase -{ - protected $isInstalled = false; - - /** - * @test - */ - public function allows_forum_installation() - { - if (file_exists(base_path('config.php'))) { - unlink(base_path('config.php')); - } - - /** @var InstallCommand $command */ - $command = app(InstallCommand::class); - $command->setDataSource($this->configuration); - - $body = fopen('php://temp', 'wb+'); - $input = new StringInput(''); - $output = new StreamOutput($body); - - $command->run($input, $output); - - $this->assertFileExists(base_path('config.php')); - - $admin = $this->configuration->getAdminUser(); - - $this->assertEquals( - $this->getDatabase()->table('users')->find(1)->username, - $admin['username'] - ); - } - - private function getDatabase() - { - $factory = new ConnectionFactory(app()); - - return $factory->make($this->configuration->getDatabaseConfiguration()); - } -} diff --git a/framework/core/tests/Install/DefaultInstallationTest.php b/framework/core/tests/Install/DefaultInstallationTest.php new file mode 100644 index 000000000..27eac7a42 --- /dev/null +++ b/framework/core/tests/Install/DefaultInstallationTest.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\Tests\Install; + +use Flarum\Install\AdminUser; +use Flarum\Install\Installation; +use Flarum\Tests\Test\TestCase; +use Illuminate\Database\Connectors\ConnectionFactory; + +class DefaultInstallationTest extends TestCase +{ + protected $isInstalled = false; + + /** + * @test + */ + public function allows_forum_installation() + { + if (file_exists(base_path('config.php'))) { + unlink(base_path('config.php')); + } + + /** @var Installation $installation */ + $installation = app(Installation::class); + + $installation + ->debugMode(true) + ->baseUrl('http://flarum.local') + ->databaseConfig($this->getDatabaseConfiguration()) + ->adminUser($this->getAdmin()) + ->settings($this->getSettings()) + ->build()->run(); + + $this->assertFileExists(base_path('config.php')); + + $this->assertEquals( + $this->getDatabase()->table('users')->find(1)->username, + 'admin' + ); + } + + private function getDatabase() + { + $factory = new ConnectionFactory(app()); + + return $factory->make($this->getDatabaseConfiguration()->toArray()); + } + + private function getAdmin(): AdminUser + { + return new AdminUser( + 'admin', + 'password', + 'admin@example.com' + ); + } + + private function getSettings() + { + return [ + 'forum_title' => 'Development Forum', + 'mail_driver' => 'log', + 'welcome_title' => 'Welcome to Development Forum', + ]; + } +} diff --git a/framework/core/tests/Test/Concerns/CreatesForum.php b/framework/core/tests/Test/Concerns/CreatesForum.php index a16b2269d..643afe5b1 100644 --- a/framework/core/tests/Test/Concerns/CreatesForum.php +++ b/framework/core/tests/Test/Concerns/CreatesForum.php @@ -19,9 +19,11 @@ use Flarum\Foundation\SiteInterface; use Flarum\Foundation\UninstalledSite; use Flarum\Http\Server; use Flarum\Install\Console\DataProviderInterface; -use Flarum\Install\Console\DefaultsDataProvider; +use Flarum\Install\DatabaseConfig; use Illuminate\Database\ConnectionInterface; -use Illuminate\Database\Connectors\ConnectionFactory; +use Illuminate\Database\Connectors\MySqlConnector; +use Illuminate\Database\MySqlConnection; +use Illuminate\Filesystem\Filesystem; trait CreatesForum { @@ -72,26 +74,21 @@ trait CreatesForum $this->app = $this->site->bootApp(); } - protected function collectsConfiguration() + protected function getDatabaseConfiguration(): DatabaseConfig { - $this->configuration = new DefaultsDataProvider(); - - $this->configuration->setDebugMode(); - $this->configuration->setSetting('mail_driver', 'log'); - - $database = $this->configuration->getDatabaseConfiguration(); - $database['host'] = env('DB_HOST', $database['host']); - $database['database'] = env('DB_DATABASE', $database['database']); - $database['username'] = env('DB_USERNAME', $database['username']); - $database['password'] = env('DB_PASSWORD', $database['password']); - $database['prefix'] = env('DB_PREFIX', $database['prefix']); - $this->configuration->setDatabaseConfiguration($database); + return new DatabaseConfig( + 'mysql', + env('DB_HOST', 'localhost'), + 3306, + env('DB_DATABASE', 'flarum'), + env('DB_USERNAME', 'root'), + env('DB_PASSWORD', ''), + env('DB_PREFIX', '') + ); } protected function refreshApplication() { - $this->collectsConfiguration(); - $this->seedsDatabase(); $this->createsSite(); @@ -108,23 +105,10 @@ trait CreatesForum protected function getFlarumConfig() { - $dbConfig = $this->configuration->getDatabaseConfiguration(); - return [ - 'debug' => $this->configuration->isDebugMode(), - 'database' => [ - '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->configuration->getBaseUrl(), + 'debug' => true, + 'database' => $this->getDatabaseConfiguration()->toArray(), + 'url' => 'http://flarum.local', 'paths' => [ 'api' => 'api', 'admin' => 'admin', @@ -138,13 +122,13 @@ trait CreatesForum return; } - $app = app(\Illuminate\Contracts\Foundation\Application::class); + $dbConfig = $this->getDatabaseConfiguration()->toArray(); - $factory = new ConnectionFactory($app); - $db = $factory->make($this->configuration->getDatabaseConfiguration()); + $pdo = (new MySqlConnector)->connect($dbConfig); + $db = new MySqlConnection($pdo, $dbConfig['database'], $dbConfig['prefix'], $dbConfig); $repository = new DatabaseMigrationRepository($db, 'migrations'); - $migrator = new Migrator($repository, $db, app('files')); + $migrator = new Migrator($repository, $db, new Filesystem); if (! $migrator->getRepository()->repositoryExists()) { $migrator->getRepository()->createRepository(); diff --git a/framework/core/views/install/app.php b/framework/core/views/install/app.php index 2e8601022..3e4f572de 100644 --- a/framework/core/views/install/app.php +++ b/framework/core/views/install/app.php @@ -129,30 +129,30 @@ animation-name: fadeIn; } - .Errors { + .Problems { margin-top: 50px; } - .Errors .Error:first-child { + .Problems .Problem:first-child { border-top-left-radius: 4px; border-top-right-radius: 4px; } - .Errors .Error:last-child { + .Problems .Problem:last-child { border-bottom-left-radius: 4px; border-bottom-right-radius: 4px; } - .Error { + .Problem { background: #EDF2F7; margin: 0 0 1px; padding: 20px 25px; text-align: left; } - .Error-message { + .Problem-message { font-size: 16px; color: #3C5675; font-weight: normal; margin: 0; } - .Error-detail { + .Problem-detail { font-size: 13px; margin: 5px 0 0; } diff --git a/framework/core/views/install/errors.php b/framework/core/views/install/errors.php deleted file mode 100644 index bd73aeb43..000000000 --- a/framework/core/views/install/errors.php +++ /dev/null @@ -1,14 +0,0 @@ -

Hold Up!

- -

These errors must be resolved before you can continue the installation. If you're having trouble, get help on the Flarum website.

- -
- -
-

- -

- -
- -
diff --git a/framework/core/views/install/problems.php b/framework/core/views/install/problems.php new file mode 100644 index 000000000..36b8d6252 --- /dev/null +++ b/framework/core/views/install/problems.php @@ -0,0 +1,14 @@ +

Hold Up!

+ +

These problems must be resolved before you can continue the installation. If you're having trouble, get help on the Flarum website.

+ +
+ +
+

+ +

+ +
+ +