1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2021 webtrees development team 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License as published by 8 * the Free Software Foundation, either version 3 of the License, or 9 * (at your option) any later version. 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 */ 17 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees\Http\RequestHandlers; 21 22use Exception; 23use Fisharebest\Localization\Locale; 24use Fisharebest\Localization\Locale\LocaleEnUs; 25use Fisharebest\Localization\Locale\LocaleInterface; 26use Fisharebest\Webtrees\Auth; 27use Fisharebest\Webtrees\Contracts\UserInterface; 28use Fisharebest\Webtrees\Factories\CacheFactory; 29use Fisharebest\Webtrees\Http\ViewResponseTrait; 30use Fisharebest\Webtrees\I18N; 31use Fisharebest\Webtrees\Module\ModuleLanguageInterface; 32use Fisharebest\Webtrees\Registry; 33use Fisharebest\Webtrees\Services\MigrationService; 34use Fisharebest\Webtrees\Services\ModuleService; 35use Fisharebest\Webtrees\Services\ServerCheckService; 36use Fisharebest\Webtrees\Services\UserService; 37use Fisharebest\Webtrees\Session; 38use Fisharebest\Webtrees\Webtrees; 39use Illuminate\Database\Capsule\Manager as DB; 40use Psr\Http\Message\ResponseInterface; 41use Psr\Http\Message\ServerRequestInterface; 42use Psr\Http\Server\RequestHandlerInterface; 43use Throwable; 44 45use function app; 46use function e; 47use function file_get_contents; 48use function file_put_contents; 49use function ini_get; 50use function random_bytes; 51use function realpath; 52use function redirect; 53use function substr; 54use function touch; 55use function unlink; 56use function view; 57 58/** 59 * Controller for the installation wizard 60 */ 61class SetupWizard implements RequestHandlerInterface 62{ 63 use ViewResponseTrait; 64 65 private const DEFAULT_DBTYPE = 'mysql'; 66 private const DEFAULT_PREFIX = 'wt_'; 67 private const DEFAULT_DATA = [ 68 'baseurl' => '', 69 'lang' => '', 70 'dbtype' => self::DEFAULT_DBTYPE, 71 'dbhost' => '', 72 'dbport' => '', 73 'dbuser' => '', 74 'dbpass' => '', 75 'dbname' => '', 76 'tblpfx' => self::DEFAULT_PREFIX, 77 'wtname' => '', 78 'wtuser' => '', 79 'wtpass' => '', 80 'wtemail' => '', 81 ]; 82 83 private const DEFAULT_PORTS = [ 84 'mysql' => '3306', 85 'pgsql' => '5432', 86 'sqlite' => '', 87 'sqlsvr' => '1433', 88 ]; 89 90 /** @var MigrationService */ 91 private $migration_service; 92 93 /** @var ModuleService */ 94 private $module_service; 95 96 /** @var ServerCheckService */ 97 private $server_check_service; 98 99 /** @var UserService */ 100 private $user_service; 101 102 /** 103 * SetupWizard constructor. 104 * 105 * @param MigrationService $migration_service 106 * @param ModuleService $module_service 107 * @param ServerCheckService $server_check_service 108 * @param UserService $user_service 109 */ 110 public function __construct( 111 MigrationService $migration_service, 112 ModuleService $module_service, 113 ServerCheckService $server_check_service, 114 UserService $user_service 115 ) { 116 $this->user_service = $user_service; 117 $this->migration_service = $migration_service; 118 $this->module_service = $module_service; 119 $this->server_check_service = $server_check_service; 120 } 121 122 /** 123 * Installation wizard - check user input and proceed to the next step. 124 * 125 * @param ServerRequestInterface $request 126 * 127 * @return ResponseInterface 128 */ 129 public function handle(ServerRequestInterface $request): ResponseInterface 130 { 131 $this->layout = 'layouts/setup'; 132 133 // Some functions need a cache, but we don't have one yet. 134 Registry::cache(new CacheFactory()); 135 136 // We will need an IP address for the logs. 137 $ip_address = $request->getServerParams()['REMOTE_ADDR'] ?? '127.0.0.1'; 138 $request = $request->withAttribute('client-ip', $ip_address); 139 140 app()->instance(ServerRequestInterface::class, $request); 141 142 $data = $this->userData($request); 143 144 $params = (array) $request->getParsedBody(); 145 $step = (int) ($params['step'] ?? '1'); 146 147 $locales = $this->module_service 148 ->setupLanguages() 149 ->map(static function (ModuleLanguageInterface $module): LocaleInterface { 150 return $module->locale(); 151 }); 152 153 if ($data['lang'] === '') { 154 $default = new LocaleEnUs(); 155 156 $locale = Locale::httpAcceptLanguage($request->getServerParams(), $locales->all(), $default); 157 158 $data['lang'] = $locale->languageTag(); 159 } 160 161 I18N::init($data['lang'], true); 162 163 $data['cpu_limit'] = $this->maxExecutionTime(); 164 $data['locales'] = $locales; 165 $data['memory_limit'] = $this->memoryLimit(); 166 167 // Only show database errors after the user has chosen a driver. 168 if ($step >= 4) { 169 $data['errors'] = $this->server_check_service->serverErrors($data['dbtype']); 170 $data['warnings'] = $this->server_check_service->serverWarnings($data['dbtype']); 171 } else { 172 $data['errors'] = $this->server_check_service->serverErrors(); 173 $data['warnings'] = $this->server_check_service->serverWarnings(); 174 } 175 176 if (!$this->checkFolderIsWritable(Webtrees::DATA_DIR)) { 177 $data['errors']->push( 178 '<code>' . e(realpath(Webtrees::DATA_DIR)) . '</code><br>' . 179 I18N::translate('Oops! webtrees was unable to create files in this folder.') . ' ' . 180 I18N::translate('This usually means that you need to change the folder permissions to 777.') 181 ); 182 } 183 184 switch ($step) { 185 default: 186 case 1: 187 return $this->step1Language($data); 188 case 2: 189 return $this->step2CheckServer($data); 190 case 3: 191 return $this->step3DatabaseType($data); 192 case 4: 193 return $this->step4DatabaseConnection($data); 194 case 5: 195 return $this->step5Administrator($data); 196 case 6: 197 return $this->step6Install($data); 198 } 199 } 200 201 /** 202 * @param ServerRequestInterface $request 203 * 204 * @return array<string,mixed> 205 */ 206 private function userData(ServerRequestInterface $request): array 207 { 208 $params = (array) $request->getParsedBody(); 209 210 $data = []; 211 212 foreach (self::DEFAULT_DATA as $key => $default) { 213 $data[$key] = $params[$key] ?? $default; 214 } 215 216 return $data; 217 } 218 219 /** 220 * The server's memory limit 221 * 222 * @return int 223 */ 224 private function maxExecutionTime(): int 225 { 226 return (int) ini_get('max_execution_time'); 227 } 228 229 /** 230 * The server's memory limit (in MB). 231 * 232 * @return int 233 */ 234 private function memoryLimit(): int 235 { 236 $memory_limit = ini_get('memory_limit'); 237 238 $number = (int) $memory_limit; 239 240 switch (substr($memory_limit, -1)) { 241 case 'g': 242 case 'G': 243 return $number * 1024; 244 case 'm': 245 case 'M': 246 return $number; 247 case 'k': 248 case 'K': 249 return (int) ($number / 1024); 250 default: 251 return (int) ($number / 1048576); 252 } 253 } 254 255 /** 256 * Check we can write to the data folder. 257 * 258 * @param string $data_dir 259 * 260 * @return bool 261 */ 262 private function checkFolderIsWritable(string $data_dir): bool 263 { 264 $text1 = random_bytes(32); 265 266 try { 267 file_put_contents($data_dir . 'test.txt', $text1); 268 $text2 = file_get_contents(Webtrees::DATA_DIR . 'test.txt'); 269 unlink(Webtrees::DATA_DIR . 'test.txt'); 270 } catch (Exception $ex) { 271 return false; 272 } 273 274 return $text1 === $text2; 275 } 276 277 /** 278 * @param array<string,mixed> $data 279 * 280 * @return ResponseInterface 281 */ 282 private function step1Language(array $data): ResponseInterface 283 { 284 return $this->viewResponse('setup/step-1-language', $data); 285 } 286 287 /** 288 * @param array<string,mixed> $data 289 * 290 * @return ResponseInterface 291 */ 292 private function step2CheckServer(array $data): ResponseInterface 293 { 294 return $this->viewResponse('setup/step-2-server-checks', $data); 295 } 296 297 /** 298 * @param array<string,mixed> $data 299 * 300 * @return ResponseInterface 301 */ 302 private function step3DatabaseType(array $data): ResponseInterface 303 { 304 if ($data['errors']->isNotEmpty()) { 305 return $this->viewResponse('setup/step-2-server-checks', $data); 306 } 307 308 return $this->viewResponse('setup/step-3-database-type', $data); 309 } 310 311 /** 312 * @param array<string,mixed> $data 313 * 314 * @return ResponseInterface 315 */ 316 private function step4DatabaseConnection(array $data): ResponseInterface 317 { 318 if ($data['errors']->isNotEmpty()) { 319 return $this->step3DatabaseType($data); 320 } 321 322 return $this->viewResponse('setup/step-4-database-' . $data['dbtype'], $data); 323 } 324 325 /** 326 * @param array<string,mixed> $data 327 * 328 * @return ResponseInterface 329 */ 330 private function step5Administrator(array $data): ResponseInterface 331 { 332 // Use default port, if none specified. 333 $data['dbport'] = $data['dbport'] ?: self::DEFAULT_PORTS[$data['dbtype']]; 334 335 try { 336 $this->connectToDatabase($data); 337 } catch (Throwable $ex) { 338 $data['errors']->push($ex->getMessage()); 339 340 // Don't jump to step 4, as the error will make it jump to step 3. 341 return $this->viewResponse('setup/step-4-database-' . $data['dbtype'], $data); 342 } 343 344 return $this->viewResponse('setup/step-5-administrator', $data); 345 } 346 347 /** 348 * @param array<string,mixed> $data 349 * 350 * @return ResponseInterface 351 */ 352 private function step6Install(array $data): ResponseInterface 353 { 354 $error = $this->checkAdminUser($data['wtname'], $data['wtuser'], $data['wtpass'], $data['wtemail']); 355 356 if ($error !== '') { 357 $data['errors']->push($error); 358 359 return $this->step5Administrator($data); 360 } 361 362 try { 363 $this->createConfigFile($data); 364 } catch (Throwable $exception) { 365 return $this->viewResponse('setup/step-6-failed', ['exception' => $exception]); 366 } 367 368 // Done - start using webtrees! 369 return redirect($data['baseurl']); 370 } 371 372 /** 373 * @param string $wtname 374 * @param string $wtuser 375 * @param string $wtpass 376 * @param string $wtemail 377 * 378 * @return string 379 */ 380 private function checkAdminUser(string $wtname, string $wtuser, string $wtpass, string $wtemail): string 381 { 382 if ($wtname === '' || $wtuser === '' || $wtpass === '' || $wtemail === '') { 383 return I18N::translate('You must enter all the administrator account fields.'); 384 } 385 386 if (mb_strlen($wtpass) < 6) { 387 return I18N::translate('The password needs to be at least six characters long.'); 388 } 389 390 return ''; 391 } 392 393 /** 394 * @param array<string,mixed> $data 395 * 396 * @return void 397 */ 398 private function createConfigFile(array $data): void 399 { 400 // Create/update the database tables. 401 $this->connectToDatabase($data); 402 $this->migration_service->updateSchema('\Fisharebest\Webtrees\Schema', 'WT_SCHEMA_VERSION', Webtrees::SCHEMA_VERSION); 403 404 // Add some default/necessary configuration data. 405 $this->migration_service->seedDatabase(); 406 407 // If we are re-installing, then this user may already exist. 408 $admin = $this->user_service->findByIdentifier($data['wtemail']); 409 if ($admin === null) { 410 $admin = $this->user_service->findByIdentifier($data['wtuser']); 411 } 412 // Create the user 413 if ($admin === null) { 414 $admin = $this->user_service->create($data['wtuser'], $data['wtname'], $data['wtemail'], $data['wtpass']); 415 $admin->setPreference(UserInterface::PREF_LANGUAGE, $data['lang']); 416 $admin->setPreference(UserInterface::PREF_IS_VISIBLE_ONLINE, '1'); 417 } else { 418 $admin->setPassword($_POST['wtpass']); 419 } 420 // Make the user an administrator 421 $admin->setPreference(UserInterface::PREF_IS_ADMINISTRATOR, '1'); 422 $admin->setPreference(UserInterface::PREF_IS_EMAIL_VERIFIED, '1'); 423 $admin->setPreference(UserInterface::PREF_IS_ACCOUNT_APPROVED, '1'); 424 425 // Write the config file. We already checked that this would work. 426 $config_ini_php = view('setup/config.ini', $data); 427 428 file_put_contents(Webtrees::CONFIG_FILE, $config_ini_php); 429 430 // Login as the new user 431 $request = app(ServerRequestInterface::class) 432 ->withAttribute('base_url', $data['baseurl']); 433 434 Session::start($request); 435 Auth::login($admin); 436 Session::put('language', $data['lang']); 437 } 438 439 /** 440 * @param array<string,mixed> $data 441 * 442 * @return void 443 */ 444 private function connectToDatabase(array $data): void 445 { 446 $capsule = new DB(); 447 448 // Try to create the database, if it does not already exist. 449 switch ($data['dbtype']) { 450 case 'sqlite': 451 $data['dbname'] = Webtrees::ROOT_DIR . 'data/' . $data['dbname'] . '.sqlite'; 452 touch($data['dbname']); 453 break; 454 455 case 'mysql': 456 $capsule->addConnection([ 457 'driver' => $data['dbtype'], 458 'host' => $data['dbhost'], 459 'port' => $data['dbport'], 460 'database' => '', 461 'username' => $data['dbuser'], 462 'password' => $data['dbpass'], 463 ], 'temp'); 464 $capsule->getConnection('temp')->statement('CREATE DATABASE IF NOT EXISTS `' . $data['dbname'] . '` COLLATE utf8_unicode_ci'); 465 break; 466 } 467 468 // Connect to the database. 469 $capsule->addConnection([ 470 'driver' => $data['dbtype'], 471 'host' => $data['dbhost'], 472 'port' => $data['dbport'], 473 'database' => $data['dbname'], 474 'username' => $data['dbuser'], 475 'password' => $data['dbpass'], 476 'prefix' => $data['tblpfx'], 477 'prefix_indexes' => true, 478 // For MySQL 479 'charset' => 'utf8', 480 'collation' => 'utf8_unicode_ci', 481 'timezone' => '+00:00', 482 'engine' => 'InnoDB', 483 'modes' => [ 484 'ANSI', 485 'STRICT_TRANS_TABLES', 486 'NO_ZERO_IN_DATE', 487 'NO_ZERO_DATE', 488 'ERROR_FOR_DIVISION_BY_ZERO', 489 ], 490 // For SQLite 491 'foreign_key_constraints' => true, 492 ]); 493 494 $capsule->setAsGlobal(); 495 } 496} 497