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