diff --git a/app/Checks/AdminUserCheck.php b/app/Checks/AdminUserCheck.php new file mode 100644 index 0000000000..b6bd98b8a4 --- /dev/null +++ b/app/Checks/AdminUserCheck.php @@ -0,0 +1,32 @@ +meta([ + 'remediation' => trans('installer.health.admin.remediation'), + ]); + + try { + return User::role(Role::ROOT_ADMIN)->exists() + ? $result->ok(trans('installer.health.admin.passed')) + : $result->failed(trans('installer.health.admin.failed')); + } catch (Throwable $exception) { + return $result->failed(trans('installer.health.admin.exception', [ + 'error' => $exception->getMessage(), + ])); + } + } +} diff --git a/app/Checks/ApplicationKeyCheck.php b/app/Checks/ApplicationKeyCheck.php new file mode 100644 index 0000000000..b35329128c --- /dev/null +++ b/app/Checks/ApplicationKeyCheck.php @@ -0,0 +1,35 @@ +meta([ + 'remediation' => trans('installer.health.app_key.remediation'), + ]); + + return $passed + ? $result->ok(trans('installer.health.app_key.passed')) + : $result->failed(trans('installer.health.app_key.failed')); + } +} diff --git a/app/Checks/DatabaseExtensionCheck.php b/app/Checks/DatabaseExtensionCheck.php new file mode 100644 index 0000000000..f4463a1888 --- /dev/null +++ b/app/Checks/DatabaseExtensionCheck.php @@ -0,0 +1,55 @@ +driver = $driver; + + return $this; + } + + /** + * Verify that the configured database driver is supported and available. + */ + public function run(): Result + { + $driver = is_string($this->driver) ? DatabaseDriver::tryFrom($this->driver) : $this->driver; + + if ($driver === null) { + return Result::make() + ->meta([ + 'driver' => $this->driver, + 'remediation' => trans('installer.health.database_extension.unsupported_remediation'), + ]) + ->failed(trans('installer.health.database_extension.unsupported', ['driver' => $this->driver])); + } + + $extension = $driver->requiredExtension(); + $passed = extension_loaded($extension); + + $result = Result::make()->meta([ + 'driver' => $driver->value, + 'extension' => $extension, + 'remediation' => trans('installer.health.extensions.remediation'), + ]); + + return $passed + ? $result->ok(trans('installer.health.database_extension.passed', ['extension' => $extension])) + : $result->failed(trans('installer.health.database_extension.failed', [ + 'driver' => $driver->getLabel(), + 'extension' => $extension, + ])); + } +} diff --git a/app/Checks/InstallationFlagCheck.php b/app/Checks/InstallationFlagCheck.php new file mode 100644 index 0000000000..009496479d --- /dev/null +++ b/app/Checks/InstallationFlagCheck.php @@ -0,0 +1,23 @@ +meta([ + 'remediation' => trans('installer.health.installed.remediation'), + ]); + + return config('app.installed') + ? $result->ok(trans('installer.health.installed.passed')) + : $result->failed(trans('installer.health.installed.failed')); + } +} diff --git a/app/Checks/MigrationsCheck.php b/app/Checks/MigrationsCheck.php new file mode 100644 index 0000000000..c7da54b020 --- /dev/null +++ b/app/Checks/MigrationsCheck.php @@ -0,0 +1,43 @@ +meta([ + 'remediation' => trans('installer.health.migrations.remediation'), + ]); + + try { + if (!$this->migrator->repositoryExists()) { + return $result->failed(trans('installer.health.migrations.repository_missing')); + } + + $files = $this->migrator->getMigrationFiles(database_path('migrations')); + $pending = array_diff(array_keys($files), $this->migrator->getRepository()->getRan()); + + return $pending === [] + ? $result->ok(trans('installer.health.migrations.passed')) + : $result->failed(trans('installer.health.migrations.failed', ['count' => count($pending)])); + } catch (Throwable $exception) { + return $result->failed(trans('installer.health.migrations.exception', [ + 'error' => $exception->getMessage(), + ])); + } + } +} diff --git a/app/Checks/PhpExtensionsCheck.php b/app/Checks/PhpExtensionsCheck.php new file mode 100644 index 0000000000..688bbc1d1e --- /dev/null +++ b/app/Checks/PhpExtensionsCheck.php @@ -0,0 +1,70 @@ +requiredExtensions = $extensions; + + return $this; + } + + /** + * Verify the required PHP and PDO extensions are loaded. + */ + public function run(): Result + { + $missing = array_values(array_filter( + $this->requiredExtensions, + fn (string $extension) => !extension_loaded($extension), + )); + + $databaseExtensions = array_unique(array_map( + fn (DatabaseDriver $driver) => $driver->requiredExtension(), + DatabaseDriver::cases(), + )); + + $hasDatabaseExtension = array_filter($databaseExtensions, extension_loaded(...)) !== []; + + if (!$hasDatabaseExtension) { + $missing[] = implode(', ', $databaseExtensions); + } + + $result = Result::make()->meta([ + 'missing' => implode(', ', $missing), + 'remediation' => trans('installer.health.extensions.remediation'), + ]); + + return $missing === [] + ? $result->ok(trans('installer.health.extensions.passed')) + : $result->failed(trans('installer.health.extensions.failed', [ + 'extensions' => implode(', ', $missing), + ])); + } +} diff --git a/app/Checks/PhpVersionCheck.php b/app/Checks/PhpVersionCheck.php new file mode 100644 index 0000000000..a4d21dc718 --- /dev/null +++ b/app/Checks/PhpVersionCheck.php @@ -0,0 +1,61 @@ +minimumVersion = $version; + + return $this; + } + + /** + * Override the current PHP version for evaluation. + */ + public function currentVersion(string $version): self + { + $this->currentVersion = $version; + + return $this; + } + + /** + * Verify that the current PHP version meets the minimum requirement. + */ + public function run(): Result + { + $passed = version_compare($this->currentVersion, $this->minimumVersion, '>='); + + $result = Result::make() + ->meta([ + 'current' => $this->currentVersion, + 'minimum' => $this->minimumVersion, + 'remediation' => trans('installer.health.php.remediation', ['minimum' => $this->minimumVersion]), + ]) + ->shortSummary($this->currentVersion); + + return $passed + ? $result->ok(trans('installer.health.php.passed', [ + 'current' => $this->currentVersion, + 'minimum' => $this->minimumVersion, + ])) + : $result->failed(trans('installer.health.php.failed', [ + 'current' => $this->currentVersion, + 'minimum' => $this->minimumVersion, + ])); + } +} diff --git a/app/Checks/WritablePathsCheck.php b/app/Checks/WritablePathsCheck.php new file mode 100644 index 0000000000..93424800da --- /dev/null +++ b/app/Checks/WritablePathsCheck.php @@ -0,0 +1,53 @@ +paths = $paths; + + return $this; + } + + /** + * Verify that every required installer path is writable. + */ + public function run(): Result + { + $paths = $this->paths ?? [ + storage_path(), + base_path('bootstrap/cache'), + file_exists(base_path('.env')) ? base_path('.env') : base_path(), + ]; + + $notWritable = array_values(array_filter( + $paths, + fn (string $path) => !is_writable($path), + )); + + $result = Result::make()->meta([ + 'paths' => implode(', ', $paths), + 'not_writable' => implode(', ', $notWritable), + 'remediation' => trans('installer.health.paths.remediation'), + ]); + + return $notWritable === [] + ? $result->ok(trans('installer.health.paths.passed')) + : $result->failed(trans('installer.health.paths.failed', [ + 'paths' => implode(', ', $notWritable), + ])); + } +} diff --git a/app/Console/Commands/Environment/AppSettingsCommand.php b/app/Console/Commands/Environment/AppSettingsCommand.php index d3dabfba7f..0495d2689d 100644 --- a/app/Console/Commands/Environment/AppSettingsCommand.php +++ b/app/Console/Commands/Environment/AppSettingsCommand.php @@ -2,21 +2,39 @@ namespace App\Console\Commands\Environment; +use App\Services\Environment\InstallationHealthService; +use App\Traits\Commands\DisplaysHealthResults; use App\Traits\EnvironmentWriterTrait; use Exception; use Illuminate\Console\Command; class AppSettingsCommand extends Command { + use DisplaysHealthResults; use EnvironmentWriterTrait; protected $description = 'Configure basic environment settings for the Panel.'; protected $signature = 'p:environment:setup - {--url= : The URL that this Panel is running on.}'; + {--url= : The URL that this Panel is running on.} + {--skip-preflight : Skip server requirement checks before setup.}'; - public function handle(): int + /** + * Configure the application after enforcing the installer preflight checks. + */ + public function handle(InstallationHealthService $health): int { + if (!$this->option('skip-preflight')) { + $results = $health->systemRequirements(); + $this->displayHealthResults($results); + + if ($health->hasFailures($results)) { + $this->error(trans('commands.environment_check.preflight_failed')); + + return self::FAILURE; + } + } + $path = base_path('.env'); if (!file_exists($path)) { $this->comment('Copying example .env file'); @@ -67,8 +85,13 @@ private function handleAppUrl(): bool return false; } - if (!str_starts_with($appUrl, 'http://') && !str_starts_with($appUrl, 'https://')) { - $this->error('Application URL need to start with either http:// or https://.'); + $url = is_string($appUrl) ? parse_url($appUrl) : false; + if ( + !is_array($url) + || !in_array($url['scheme'] ?? null, ['http', 'https'], true) + || blank($url['host'] ?? null) + ) { + $this->error('Application URL must be a valid HTTP or HTTPS URL.'); return false; } diff --git a/app/Console/Commands/Environment/DatabaseSettingsCommand.php b/app/Console/Commands/Environment/DatabaseSettingsCommand.php index 3953c5b6f4..e413095125 100644 --- a/app/Console/Commands/Environment/DatabaseSettingsCommand.php +++ b/app/Console/Commands/Environment/DatabaseSettingsCommand.php @@ -2,6 +2,7 @@ namespace App\Console\Commands\Environment; +use App\Enums\DatabaseDriver; use App\Traits\EnvironmentWriterTrait; use Illuminate\Console\Command; use Illuminate\Contracts\Console\Kernel; @@ -12,13 +13,6 @@ class DatabaseSettingsCommand extends Command { use EnvironmentWriterTrait; - public const DATABASE_DRIVERS = [ - 'sqlite' => 'SQLite (recommended)', - 'mariadb' => 'MariaDB', - 'mysql' => 'MySQL', - 'pgsql' => 'PostgreSQL', - ]; - protected $description = 'Configure database settings for the Panel.'; protected $signature = 'p:environment:database @@ -46,6 +40,13 @@ public function __construct(private DatabaseManager $database, private Kernel $c */ public function handle(): int { + $driver = $this->option('driver'); + if ($driver !== null && (!is_string($driver) || DatabaseDriver::tryFrom($driver) === null)) { + $this->error(sprintf('Unsupported database driver [%s].', is_scalar($driver) ? $driver : get_debug_type($driver))); + + return self::FAILURE; + } + $this->error('Changing the database driver will NOT move any database data!'); $this->error('Please make sure you made a database backup first!'); $this->error('After changing the driver you will have to manually move the old data to the new database.'); @@ -54,10 +55,11 @@ public function handle(): int } $selected = config('database.default', 'sqlite'); - $this->variables['DB_CONNECTION'] = $this->option('driver') ?? $this->choice( + $databaseDrivers = DatabaseDriver::options(recommendSQLite: true); + $this->variables['DB_CONNECTION'] = $driver ?? $this->choice( 'Database Driver', - self::DATABASE_DRIVERS, - array_key_exists($selected, self::DATABASE_DRIVERS) ? $selected : null + $databaseDrivers, + array_key_exists($selected, $databaseDrivers) ? $selected : null ); if ($this->variables['DB_CONNECTION'] === 'mysql') { diff --git a/app/Console/Commands/Environment/EnvironmentHealthCommand.php b/app/Console/Commands/Environment/EnvironmentHealthCommand.php new file mode 100644 index 0000000000..5cd28b9565 --- /dev/null +++ b/app/Console/Commands/Environment/EnvironmentHealthCommand.php @@ -0,0 +1,36 @@ +completeInstallation(); + + $this->displayHealthResults($results); + + if ($health->hasFailures($results)) { + $this->error(trans('commands.environment_check.health_failed')); + + return self::FAILURE; + } + + $this->info(trans('commands.environment_check.health_passed')); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/Environment/EnvironmentPreflightCommand.php b/app/Console/Commands/Environment/EnvironmentPreflightCommand.php new file mode 100644 index 0000000000..91dcd662e6 --- /dev/null +++ b/app/Console/Commands/Environment/EnvironmentPreflightCommand.php @@ -0,0 +1,49 @@ +systemRequirements(); + + if ($this->option('with-database')) { + $driverResult = $health->databaseDriverExtension((string) config('database.default')); + $results->push($driverResult); + + if (!$health->hasFailures([$driverResult])) { + $results->push($health->runCheck( + DatabaseCheck::new()->label(trans('installer.health.database.label')), + )); + } + } + + $this->displayHealthResults($results); + + if ($health->hasFailures($results)) { + $this->error(trans('commands.environment_check.preflight_failed')); + + return self::FAILURE; + } + + $this->info(trans('commands.environment_check.preflight_passed')); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/User/MakeUserCommand.php b/app/Console/Commands/User/MakeUserCommand.php index 821765770c..b5d6253e92 100644 --- a/app/Console/Commands/User/MakeUserCommand.php +++ b/app/Console/Commands/User/MakeUserCommand.php @@ -14,14 +14,6 @@ class MakeUserCommand extends Command protected $signature = 'p:user:make {--email=} {--username=} {--password=} {--admin=} {--no-password}'; - /** - * MakeUserCommand constructor. - */ - public function __construct(private UserCreationService $creationService) - { - parent::__construct(); - } - /** * Handle command request to create a new user. * @@ -38,6 +30,9 @@ public function handle(): int return 1; } + /** @var UserCreationService $creationService */ + $creationService = $this->getLaravel()->make(UserCreationService::class); + $root_admin = $this->option('admin') ?? $this->confirm(trans('command/messages.user.ask_admin')); $email = $this->option('email') ?? $this->ask(trans('command/messages.user.ask_email')); $username = $this->option('username') ?? $this->ask(trans('command/messages.user.ask_username')); @@ -48,7 +43,7 @@ public function handle(): int $password = $this->secret(trans('command/messages.user.ask_password')); } - $user = $this->creationService->handle(compact('email', 'username', 'password', 'root_admin')); + $user = $creationService->handle(compact('email', 'username', 'password', 'root_admin')); $this->table(['Field', 'Value'], [ ['UUID', $user->uuid], ['Email', $user->email], diff --git a/app/Enums/DatabaseDriver.php b/app/Enums/DatabaseDriver.php new file mode 100644 index 0000000000..ebf136ad14 --- /dev/null +++ b/app/Enums/DatabaseDriver.php @@ -0,0 +1,65 @@ + 'SQLite', + self::MariaDB => 'MariaDB', + self::MySQL => 'MySQL', + self::PostgreSQL => 'PostgreSQL', + }; + } + + /** + * Return the PDO extension required by this database driver. + */ + public function requiredExtension(): string + { + return match ($this) { + self::SQLite => 'pdo_sqlite', + self::MariaDB, self::MySQL => 'pdo_mysql', + self::PostgreSQL => 'pdo_pgsql', + }; + } + + /** + * Return the driver's default network port, when applicable. + */ + public function defaultPort(): ?int + { + return match ($this) { + self::SQLite => null, + self::MariaDB, self::MySQL => 3306, + self::PostgreSQL => 5432, + }; + } + + /** + * Build the database driver options shown by installers and commands. + * + * @return array + */ + public static function options(bool $recommendSQLite = false): array + { + return collect(self::cases()) + ->mapWithKeys(fn (self $driver) => [ + $driver->value => $driver->getLabel() + . ($recommendSQLite && $driver === self::SQLite ? ' (recommended)' : ''), + ]) + ->all(); + } +} diff --git a/app/Livewire/Installer/Steps/DatabaseStep.php b/app/Livewire/Installer/Steps/DatabaseStep.php index 1c22cf60c8..2cad0a03a8 100644 --- a/app/Livewire/Installer/Steps/DatabaseStep.php +++ b/app/Livewire/Installer/Steps/DatabaseStep.php @@ -2,9 +2,10 @@ namespace App\Livewire\Installer\Steps; +use App\Enums\DatabaseDriver; use App\Enums\TablerIcon; use App\Livewire\Installer\PanelInstaller; -use Exception; +use App\Services\Environment\InstallationHealthService; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\ToggleButtons; use Filament\Notifications\Notification; @@ -12,17 +13,12 @@ use Filament\Schemas\Components\Utilities\Set; use Filament\Schemas\Components\Wizard\Step; use Filament\Support\Exceptions\Halt; -use Illuminate\Support\Facades\DB; class DatabaseStep { - public const DATABASE_DRIVERS = [ - 'sqlite' => 'SQLite', - 'mariadb' => 'MariaDB', - 'mysql' => 'MySQL', - 'pgsql' => 'PostgreSQL', - ]; - + /** + * Build the database configuration step and validate it before persistence. + */ public static function make(PanelInstaller $installer): Step { return Step::make('database') @@ -34,7 +30,7 @@ public static function make(PanelInstaller $installer): Step ->hintIcon(TablerIcon::QuestionMark, trans('installer.database.driver_help')) ->required() ->inline() - ->options(self::DATABASE_DRIVERS) + ->options(DatabaseDriver::options()) ->default(config('database.default')) ->live() ->afterStateUpdated(function ($state, Set $set, Get $get) { @@ -61,9 +57,9 @@ public static function make(PanelInstaller $installer): Step } }), TextInput::make('env_database.DB_DATABASE') - ->label(fn (Get $get) => $get('env_database.DB_CONNECTION') === 'sqlite' ? trans('installer.database.fields.path') : trans('installer.database.fields.name')) - ->placeholder(fn (Get $get) => $get('env_database.DB_CONNECTION') === 'sqlite' ? 'database.sqlite' : 'panel') - ->hintIcon(TablerIcon::QuestionMark, fn (Get $get) => $get('env_database.DB_CONNECTION') === 'sqlite' ? trans('installer.database.fields.path_help') : trans('installer.database.fields.name_help')) + ->label(fn (Get $get) => $get('env_database.DB_CONNECTION') === DatabaseDriver::SQLite->value ? trans('installer.database.fields.path') : trans('installer.database.fields.name')) + ->placeholder(fn (Get $get) => $get('env_database.DB_CONNECTION') === DatabaseDriver::SQLite->value ? 'database.sqlite' : 'panel') + ->hintIcon(TablerIcon::QuestionMark, fn (Get $get) => $get('env_database.DB_CONNECTION') === DatabaseDriver::SQLite->value ? trans('installer.database.fields.path_help') : trans('installer.database.fields.name_help')) ->required() ->default(fn (Get $get) => self::getConnectionDefault($get, 'database', 'panel')), TextInput::make('env_database.DB_HOST') @@ -99,12 +95,43 @@ public static function make(PanelInstaller $installer): Step ->hintIcon(TablerIcon::QuestionMark, trans('installer.database.fields.password_help')) ->password() ->revealable() - ->hidden(fn (Get $get) => $get('env_database.DB_CONNECTION') === 'sqlite'), + ->hidden(fn (Get $get) => $get('env_database.DB_CONNECTION') === DatabaseDriver::SQLite->value), ]) ->afterValidation(function (Get $get) use ($installer) { - $driver = $get('env_database.DB_CONNECTION'); - - throw_unless(self::testConnection($driver, $get('env_database.DB_HOST'), $get('env_database.DB_PORT'), $get('env_database.DB_DATABASE'), $get('env_database.DB_USERNAME'), $get('env_database.DB_PASSWORD')), new Halt(trans('installer.database.exceptions.connection'))); + $health = app(InstallationHealthService::class); // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions + $driver = DatabaseDriver::from($get('env_database.DB_CONNECTION')); + $extensionResult = $health->databaseDriverExtension($driver); + + if ($health->hasFailures([$extensionResult])) { + Notification::make() + ->title(trans('installer.database.exceptions.extension')) + ->body($extensionResult->getNotificationMessage()) + ->danger() + ->send(); + + throw new Halt($extensionResult->getNotificationMessage()); + } + + $connectionResult = $health->databaseConnection($driver, [ + 'host' => $get('env_database.DB_HOST'), + 'port' => $get('env_database.DB_PORT'), + 'database' => $get('env_database.DB_DATABASE'), + 'username' => $get('env_database.DB_USERNAME'), + 'password' => self::getConnectionPassword( + $driver->value, + $get('env_database.DB_PASSWORD'), + ), + ]); + + if ($health->hasFailures([$connectionResult])) { + Notification::make() + ->title(trans('installer.database.exceptions.connection')) + ->body($connectionResult->getNotificationMessage()) + ->danger() + ->send(); + + throw new Halt($connectionResult->getNotificationMessage()); + } $installer->writeToEnv('env_database'); }); @@ -119,7 +146,7 @@ private static function getConnectionDefault(Get $get, string $key, mixed $fallb private static function getConfiguredConnectionValue(mixed $driver, string $key, mixed $fallback): mixed { - if ($driver === 'sqlite') { + if ($driver === DatabaseDriver::SQLite->value) { return $key === 'database' ? 'database.sqlite' : null; } @@ -130,42 +157,6 @@ private static function getConfiguredConnectionValue(mixed $driver, string $key, return config("database.connections.{$driver}.{$key}", $fallback); } - private static function testConnection(string $driver, ?string $host, null|string|int $port, ?string $database, ?string $username, ?string $password): bool - { - if ($driver === 'sqlite') { - return true; - } - - $password = self::getConnectionPassword($driver, $password); - - try { - config()->set('database.connections._panel_install_test', [ - 'driver' => $driver, - 'host' => $host, - 'port' => $port, - 'database' => $database, - 'username' => $username, - 'password' => $password, - 'collation' => 'utf8mb4_unicode_ci', - 'strict' => true, - ]); - - DB::connection('_panel_install_test')->getPdo(); - } catch (Exception $exception) { - DB::disconnect('_panel_install_test'); - - Notification::make() - ->title(trans('installer.database.exceptions.connection')) - ->body($exception->getMessage()) - ->danger() - ->send(); - - return false; - } - - return true; - } - private static function getConnectionPassword(string $driver, ?string $password): ?string { $configuredPassword = config("database.connections.{$driver}.password"); diff --git a/app/Livewire/Installer/Steps/RequirementsStep.php b/app/Livewire/Installer/Steps/RequirementsStep.php index 9954db5cb2..66fbad63e6 100644 --- a/app/Livewire/Installer/Steps/RequirementsStep.php +++ b/app/Livewire/Installer/Steps/RequirementsStep.php @@ -3,89 +3,47 @@ namespace App\Livewire\Installer\Steps; use App\Enums\TablerIcon; +use App\Services\Environment\InstallationHealthService; use Filament\Infolists\Components\TextEntry; use Filament\Notifications\Notification; use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Wizard\Step; use Filament\Support\Exceptions\Halt; +use Spatie\Health\Checks\Result; class RequirementsStep { - public const MIN_PHP_VERSION = '8.3'; - + /** + * Build the installer step that displays and enforces system requirements. + */ public static function make(): Step { - $compare = version_compare(phpversion(), self::MIN_PHP_VERSION); - $correctPhpVersion = $compare >= 0; - - $fields = [ - Section::make(trans('installer.requirements.sections.version.title')) - ->description(trans('installer.requirements.sections.version.or_newer', ['version' => self::MIN_PHP_VERSION])) - ->icon($correctPhpVersion ? TablerIcon::Check : TablerIcon::X) - ->iconColor($correctPhpVersion ? 'success' : 'danger') - ->schema([ - TextEntry::make('php_version') - ->hiddenLabel() - ->state(trans('installer.requirements.sections.version.content', ['version' => PHP_VERSION])), - ]), - ]; - - $phpExtensions = [ - 'BCMath' => extension_loaded('bcmath'), - 'cURL' => extension_loaded('curl'), - 'GD' => extension_loaded('gd'), - 'intl' => extension_loaded('intl'), - 'mbstring' => extension_loaded('mbstring'), - 'MySQL' => extension_loaded('pdo_mysql'), - 'SQLite3' => extension_loaded('pdo_sqlite'), - 'XML' => extension_loaded('xml'), - 'Zip' => extension_loaded('zip'), - ]; - $allExtensionsInstalled = !in_array(false, $phpExtensions); - - $fields[] = Section::make(trans('installer.requirements.sections.extensions.title')) - ->description(implode(', ', array_keys($phpExtensions))) - ->icon($allExtensionsInstalled ? TablerIcon::Check : TablerIcon::X) - ->iconColor($allExtensionsInstalled ? 'success' : 'danger') - ->schema([ - TextEntry::make('all_extensions_installed') - ->hiddenLabel() - ->state(trans('installer.requirements.sections.extensions.good')) - ->visible($allExtensionsInstalled), - TextEntry::make('extensions_missing') - ->hiddenLabel() - ->state(trans('installer.requirements.sections.extensions.bad', ['extensions' => implode(', ', array_keys($phpExtensions, false))])) - ->visible(!$allExtensionsInstalled), - ]); - - $folderPermissions = [ - 'Storage' => substr(sprintf('%o', fileperms(base_path('storage/'))), -4) >= 755, - 'Cache' => substr(sprintf('%o', fileperms(base_path('bootstrap/cache/'))), -4) >= 755, - ]; - $correctFolderPermissions = !in_array(false, $folderPermissions); - - $fields[] = Section::make(trans('installer.requirements.sections.permissions.title')) - ->description(implode(', ', array_keys($folderPermissions))) - ->icon($correctFolderPermissions ? TablerIcon::Check : TablerIcon::X) - ->iconColor($correctFolderPermissions ? 'success' : 'danger') - ->schema([ - TextEntry::make('correct_folder_permissions') - ->hiddenLabel() - ->state(trans('installer.requirements.sections.permissions.good')) - ->visible($correctFolderPermissions), - TextEntry::make('wrong_folder_permissions') - ->hiddenLabel() - ->state(trans('installer.requirements.sections.permissions.bad', ['folders' => implode(', ', array_keys($folderPermissions, false))])) - ->visible(!$correctFolderPermissions), - ]); + $health = app(InstallationHealthService::class); // @phpstan-ignore myCustomRules.forbiddenGlobalFunctions + $checks = $health->systemRequirements(); + + $fields = $checks + ->map(function (Result $result) use ($health): Section { + $failed = $health->hasFailures([$result]); + + return Section::make($result->check->getLabel()) + ->description($failed ? ($result->meta['remediation'] ?? null) : null) + ->icon($failed ? TablerIcon::X : TablerIcon::Check) + ->iconColor($failed ? 'danger' : 'success') + ->schema([ + TextEntry::make($result->check->getName()) + ->hiddenLabel() + ->state($result->getNotificationMessage()), + ]); + }) + ->all(); return Step::make('requirements') ->label(trans('installer.requirements.title')) ->schema($fields) - ->afterValidation(function () use ($correctPhpVersion, $allExtensionsInstalled, $correctFolderPermissions) { - if (!$correctPhpVersion || !$allExtensionsInstalled || !$correctFolderPermissions) { + ->afterValidation(function () use ($checks, $health) { + if ($health->hasFailures($checks)) { Notification::make() - ->title(trans('installer.requirements.exception')) + ->title(trans('installer.health.preflight_failed')) ->danger() ->send(); diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index c52acec545..0c3db61709 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,14 +2,21 @@ namespace App\Providers; +use App\Checks\AdminUserCheck; +use App\Checks\ApplicationKeyCheck; use App\Checks\CacheCheck; use App\Checks\DatabaseCheck; use App\Checks\DebugModeCheck; use App\Checks\EnvironmentCheck; +use App\Checks\InstallationFlagCheck; +use App\Checks\MigrationsCheck; use App\Checks\NodeVersionsCheck; use App\Checks\PanelVersionCheck; +use App\Checks\PhpExtensionsCheck; +use App\Checks\PhpVersionCheck; use App\Checks\ScheduleCheck; use App\Checks\UsedDiskSpaceCheck; +use App\Checks\WritablePathsCheck; use App\Extensions\Dedoc\Scramble\FractalResponseTypeInfer; use App\Extensions\Dedoc\Scramble\TransformerFactoryTypeInfer; use App\Extensions\Dedoc\Scramble\TransformerModelBindingExtension; @@ -111,6 +118,13 @@ public function boot( EnvironmentCheck::new(), CacheCheck::new(), DatabaseCheck::new(), + ApplicationKeyCheck::new(), + PhpVersionCheck::new(), + PhpExtensionsCheck::new(), + WritablePathsCheck::new(), + MigrationsCheck::new(), + AdminUserCheck::new(), + InstallationFlagCheck::new(), QueueCheck::new(), ScheduleCheck::new(), UsedDiskSpaceCheck::new(), diff --git a/app/Services/Environment/InstallationHealthService.php b/app/Services/Environment/InstallationHealthService.php new file mode 100644 index 0000000000..d2b482afb3 --- /dev/null +++ b/app/Services/Environment/InstallationHealthService.php @@ -0,0 +1,170 @@ + + */ + public function systemRequirements(): Collection + { + return $this->run([ + PhpVersionCheck::new()->label(trans('installer.health.php.label')), + PhpExtensionsCheck::new()->label(trans('installer.health.extensions.label')), + WritablePathsCheck::new()->label(trans('installer.health.paths.label')), + ]); + } + + /** + * Verify that a database driver is supported by the current PHP runtime. + */ + public function databaseDriverExtension(DatabaseDriver|string $driver): Result + { + return $this->runCheck( + DatabaseExtensionCheck::new() + ->driver($driver) + ->label(trans('installer.health.database_extension.label')), + ); + } + + /** + * Verify database connectivity using temporary, non-persistent settings. + * + * @param array{host?: mixed, port?: mixed, database?: mixed, username?: mixed, password?: mixed} $settings + */ + public function databaseConnection(DatabaseDriver $driver, array $settings): Result + { + $database = (string) ($settings['database'] ?? ''); + if ($driver === DatabaseDriver::SQLite && !str_starts_with($database, '/') && $database !== ':memory:') { + $database = database_path($database); + } + + $configuration = $driver === DatabaseDriver::SQLite + ? [ + 'driver' => $driver->value, + 'database' => $database, + 'prefix' => '', + 'foreign_key_constraints' => true, + ] + : [ + 'driver' => $driver->value, + 'host' => $settings['host'] ?? null, + 'port' => $settings['port'] ?? $driver->defaultPort(), + 'database' => $database, + 'username' => $settings['username'] ?? null, + 'password' => $settings['password'] ?? null, + 'collation' => 'utf8mb4_unicode_ci', + 'strict' => true, + ]; + + $connection = '_panel_install_test'; + config()->set("database.connections.{$connection}", $configuration); + DB::purge($connection); + + try { + return $this->runCheck( + DatabaseCheck::new() + ->connectionName($connection) + ->label(trans('installer.health.database.label')), + ); + } finally { + DB::disconnect($connection); + DB::purge($connection); + } + } + + /** + * Run health checks that require an already configured environment. + * + * @return Collection + */ + public function configuredEnvironment(): Collection + { + return $this->systemRequirements()->concat($this->run([ + ApplicationKeyCheck::new()->label(trans('installer.health.app_key.label')), + DatabaseCheck::new()->label(trans('installer.health.database.label')), + MigrationsCheck::new()->label(trans('installer.health.migrations.label')), + CacheCheck::new()->label(trans('installer.health.cache.label')), + ])); + } + + /** + * Run the complete set of post-installation health checks. + * + * @return Collection + */ + public function completeInstallation(): Collection + { + return $this->configuredEnvironment()->concat($this->run([ + AdminUserCheck::new()->label(trans('installer.health.admin.label')), + InstallationFlagCheck::new()->label(trans('installer.health.installed.label')), + ])); + } + + /** + * Execute a set of health checks and collect their normalized results. + * + * @param iterable $checks + * @return Collection + */ + public function run(iterable $checks): Collection + { + return collect($checks)->map(fn (Check $check) => $this->runCheck($check))->values(); + } + + /** + * Execute one health check and convert thrown errors into crashed results. + */ + public function runCheck(Check $check): Result + { + try { + $result = $check->run(); + } catch (Throwable $exception) { + report($exception); + + $result = $check->markAsCrashed() + ->notificationMessage($exception->getMessage()); + } + + return $result + ->check($check) + ->endedAt(now()); + } + + /** + * Determine whether any result represents a failed or crashed check. + * + * @param iterable $results + */ + public function hasFailures(iterable $results): bool + { + foreach ($results as $result) { + if (in_array($result->status, [Status::failed(), Status::crashed()], true)) { + return true; + } + } + + return false; + } +} diff --git a/app/Traits/Commands/DisplaysHealthResults.php b/app/Traits/Commands/DisplaysHealthResults.php new file mode 100644 index 0000000000..55eaab28e8 --- /dev/null +++ b/app/Traits/Commands/DisplaysHealthResults.php @@ -0,0 +1,50 @@ + $results + */ + protected function displayHealthResults(iterable $results): void + { + $rows = []; + + foreach ($results as $result) { + $remediation = $result->status === Status::ok() + ? null + : ($result->meta['remediation'] ?? null); + + $rows[] = [ + $result->check->getLabel(), + $this->formatHealthStatus($result->status), + $result->getNotificationMessage() . ($remediation ? "\n{$remediation}" : ''), + ]; + } + + $this->table([ + trans('commands.environment_check.check'), + trans('commands.environment_check.status'), + trans('commands.environment_check.details'), + ], $rows); + } + + /** + * Format a health status for colorized console output. + */ + private function formatHealthStatus(Status $status): string + { + return match ($status) { + Status::ok() => '' . trans('commands.environment_check.passed') . '', + Status::warning() => '' . trans('commands.environment_check.warning') . '', + Status::failed(), Status::crashed() => '' . trans('commands.environment_check.failed') . '', + default => (string) $status->value, + }; + } +} diff --git a/lang/en/commands.php b/lang/en/commands.php index a24222b3a8..f6191637df 100644 --- a/lang/en/commands.php +++ b/lang/en/commands.php @@ -1,6 +1,18 @@ [ + 'check' => 'Check', + 'status' => 'Status', + 'details' => 'Details', + 'passed' => 'Passed', + 'warning' => 'Warning', + 'failed' => 'Failed', + 'preflight_passed' => 'All requested preflight checks passed.', + 'preflight_failed' => 'Preflight failed. Resolve the checks above before continuing.', + 'health_passed' => 'The Panel installation is healthy.', + 'health_failed' => 'The Panel installation has failing health checks.', + ], 'appsettings' => [ 'comment' => [ 'author' => 'Provide the email address that eggs exported by this Panel should be from. This should be a valid email address.', diff --git a/lang/en/installer.php b/lang/en/installer.php index 43005bbc9d..45eb134302 100644 --- a/lang/en/installer.php +++ b/lang/en/installer.php @@ -23,6 +23,67 @@ ], 'exception' => 'Some requirements are missing', ], + 'health' => [ + 'preflight_failed' => 'Some server requirements are not met', + 'php' => [ + 'label' => 'PHP Version', + 'passed' => 'PHP :current meets the minimum supported version (:minimum).', + 'failed' => 'PHP :current is below the minimum supported version (:minimum).', + 'remediation' => 'Install PHP :minimum or newer before continuing.', + ], + 'extensions' => [ + 'label' => 'PHP Extensions', + 'passed' => 'All required PHP extensions are available.', + 'failed' => 'The following PHP extensions are missing: :extensions', + 'remediation' => 'Install the missing extensions for the PHP binary that runs the Panel.', + ], + 'database_extension' => [ + 'label' => 'Database Extension', + 'passed' => 'The required :extension extension is available.', + 'failed' => ':driver requires the :extension PHP extension.', + 'unsupported' => 'The configured database driver ":driver" is not supported.', + 'unsupported_remediation' => 'Set DB_CONNECTION to sqlite, mariadb, mysql, or pgsql.', + ], + 'database' => [ + 'label' => 'Database Connection', + ], + 'app_key' => [ + 'label' => 'Application Key', + 'passed' => 'The application encryption key is valid.', + 'failed' => 'The application encryption key is missing or invalid.', + 'remediation' => 'Run php artisan key:generate before continuing.', + ], + 'migrations' => [ + 'label' => 'Database Migrations', + 'passed' => 'All database migrations have completed.', + 'failed' => ':count database migration(s) are still pending.', + 'repository_missing' => 'The database migration repository does not exist.', + 'exception' => 'The migration state could not be read: :error', + 'remediation' => 'Run php artisan migrate --force and resolve any reported errors.', + ], + 'cache' => [ + 'label' => 'Cache', + ], + 'admin' => [ + 'label' => 'Administrator Account', + 'passed' => 'At least one root administrator account exists.', + 'failed' => 'No root administrator account exists.', + 'exception' => 'The administrator account check failed: :error', + 'remediation' => 'Create an administrator through the installer or php artisan p:user:make.', + ], + 'installed' => [ + 'label' => 'Installation State', + 'passed' => 'The Panel is marked as installed.', + 'failed' => 'The Panel is not marked as installed.', + 'remediation' => 'Complete the installer before serving the Panel.', + ], + 'paths' => [ + 'label' => 'Writable Paths', + 'passed' => 'The Panel can write to every required path.', + 'failed' => 'The following paths are not writable: :paths', + 'remediation' => 'Grant the web server user write access to these paths before continuing.', + ], + ], 'environment' => [ 'title' => 'Environment', 'fields' => [ @@ -57,6 +118,7 @@ 'password_help' => 'The password of your database user. Can be empty.', ], 'exceptions' => [ + 'extension' => 'Database extension missing', 'connection' => 'Database connection failed', 'migration' => 'Migrations failed', ], diff --git a/tests/Feature/Installer/InstallationHealthTest.php b/tests/Feature/Installer/InstallationHealthTest.php new file mode 100644 index 0000000000..4c12b4dc8e --- /dev/null +++ b/tests/Feature/Installer/InstallationHealthTest.php @@ -0,0 +1,188 @@ +systemRequirements(); + + expect($results)->toHaveCount(3) + ->and($results->every(fn (Result $result) => isset($result->check)))->toBeTrue(); +}); + +it('fails the PHP check below the supported version', function () { + $result = PhpVersionCheck::new() + ->currentVersion('8.2.99') + ->run(); + + expect($result->status)->toEqual(Status::failed()) + ->and($result->meta['minimum'])->toBe(PhpVersionCheck::MINIMUM_VERSION); +}); + +it('fails when a required PHP extension is unavailable', function () { + $result = PhpExtensionsCheck::new() + ->requireExtensions(['pelican_missing_extension']) + ->run(); + + expect($result->status)->toEqual(Status::failed()) + ->and($result->getNotificationMessage())->toContain('pelican_missing_extension'); +}); + +it('reports unsupported configured database drivers as failed health results', function () { + config()->set('database.default', 'invalid'); + + $result = app(InstallationHealthService::class)->databaseDriverExtension('invalid'); + + expect($result->status)->toEqual(Status::failed()) + ->and($result->getNotificationMessage())->toContain('invalid'); + + $this->artisan('p:environment:preflight', ['--with-database' => true]) + ->expectsOutputToContain('invalid') + ->doesntExpectOutputToContain('Database Connection') + ->assertFailed(); + + $this->artisan('p:user:make', [ + '--email' => 'invalid-driver@example.com', + '--username' => 'invalid-driver', + '--no-password' => true, + ]) + ->expectsOutputToContain('Database connection [invalid] not configured.') + ->assertFailed(); +}); + +it('maps database drivers to their PDO extensions and default ports', function () { + expect(DatabaseDriver::SQLite->requiredExtension())->toBe('pdo_sqlite') + ->and(DatabaseDriver::SQLite->defaultPort())->toBeNull() + ->and(DatabaseDriver::MariaDB->requiredExtension())->toBe('pdo_mysql') + ->and(DatabaseDriver::MariaDB->defaultPort())->toBe(3306) + ->and(DatabaseDriver::PostgreSQL->requiredExtension())->toBe('pdo_pgsql') + ->and(DatabaseDriver::PostgreSQL->defaultPort())->toBe(5432); +}); + +it('rejects unsupported database driver command options', function () { + $this->artisan('p:environment:database', ['--driver' => 'invalid']) + ->expectsOutput('Unsupported database driver [invalid].') + ->assertFailed(); +}); + +it('checks SQLite database paths before installer configuration is written', function () { + $health = app(InstallationHealthService::class); + + $valid = $health->databaseConnection(DatabaseDriver::SQLite, ['database' => ':memory:']); + $invalid = $health->databaseConnection(DatabaseDriver::SQLite, [ + 'database' => 'missing-directory/database.sqlite', + ]); + + expect($valid->status)->toEqual(Status::ok()) + ->and($invalid->status)->toEqual(Status::failed()); +}); + +it('reports unwritable installer paths', function () { + $result = WritablePathsCheck::new() + ->paths([base_path('pelican-path-that-does-not-exist')]) + ->run(); + + expect($result->status)->toEqual(Status::failed()) + ->and($result->getNotificationMessage())->toContain('pelican-path-that-does-not-exist'); +}); + +it('only bypasses failed setup preflight checks when explicitly requested', function () { + $failedResult = app(InstallationHealthService::class)->runCheck( + PhpVersionCheck::new() + ->minimumVersion('999.0.0') + ->label('PHP Version'), + ); + + $health = Mockery::mock(InstallationHealthService::class); + $health->shouldReceive('systemRequirements')->once()->andReturn(collect([$failedResult])); + $health->shouldReceive('hasFailures')->once()->andReturnTrue(); + $this->app->instance(InstallationHealthService::class, $health); + + $this->artisan('p:environment:setup') + ->expectsOutputToContain(trans('commands.environment_check.preflight_failed')) + ->assertFailed(); + + $this->artisan('p:environment:setup', [ + '--url' => config('app.url'), + '--skip-preflight' => true, + ]) + ->expectsOutputToContain('Creating storage link') + ->assertSuccessful(); +}); + +it('rejects malformed application URLs during setup', function (string $url) { + $this->artisan('p:environment:setup', [ + '--url' => $url, + '--skip-preflight' => true, + ]) + ->expectsOutput('Application URL must be a valid HTTP or HTTPS URL.') + ->assertFailed(); +})->with([ + 'scheme without host' => 'https://', + 'query without host' => 'http://?host=value', + 'unsupported scheme' => 'ftp://example.com', +]); + +it('runs the shared preflight from the command line', function () { + $this->artisan('p:environment:preflight', ['--with-database' => true]) + ->doesntExpectOutputToContain(trans('installer.health.php.remediation', [ + 'minimum' => PhpVersionCheck::MINIMUM_VERSION, + ])) + ->assertSuccessful(); +}); + +it('validates a complete installation after setup', function () { + config()->set('app.key', 'base64:' . base64_encode(random_bytes(32))); + config()->set('app.installed', true); + User::factory()->create()->syncRoles(Role::getRootAdmin()); + + $this->artisan('p:environment:health') + ->assertSuccessful(); +}); + +it('keeps completed installer requests safely blocked on retries', function () { + config()->set('app.installed', true); + + $this->get(route('installer'))->assertNotFound(); + $this->get(route('installer'))->assertNotFound(); +}); + +it('can repeat installer migrations and egg dispatch without crashing', function () { + Queue::fake(); + + $installer = app(PanelInstaller::class); + $installer->data = [ + 'eggs' => [ + 'minecraft' => ['https://example.com/egg.json'], + ], + ]; + + $installer->runMigrations(); + $installer->runMigrations(); + $installer->installEggs(); + $installer->installEggs(); + + Queue::assertPushed(InstallEgg::class, 2); +});