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 app()->instance('cache.array', new Cache(new NullAdapter())); 144 145 $data = $this->userData($request); 146 147 $params = (array) $request->getParsedBody(); 148 $step = (int) ($params['step'] ?? '1'); 149 150 $locales = $this->module_service 151 ->setupLanguages() 152 ->map(static function (ModuleLanguageInterface $module): LocaleInterface { 153 return $module->locale(); 154 }); 155 156 if ($data['lang'] === '') { 157 $default = new LocaleEnUs(); 158 159 $locale = Locale::httpAcceptLanguage($request->getServerParams(), $locales->all(), $default); 160 161 $data['lang'] = $locale->languageTag(); 162 } 163 164 I18N::init($data['lang'], true); 165 166 $data['cpu_limit'] = $this->maxExecutionTime(); 167 $data['locales'] = $locales->all(); 168 $data['memory_limit'] = $this->memoryLimit(); 169 170 // Only show database errors after the user has chosen a driver. 171 if ($step >= 4) { 172 $data['errors'] = $this->server_check_service->serverErrors($data['dbtype']); 173 $data['warnings'] = $this->server_check_service->serverWarnings($data['dbtype']); 174 } else { 175 $data['errors'] = $this->server_check_service->serverErrors(); 176 $data['warnings'] = $this->server_check_service->serverWarnings(); 177 } 178 179 if (!$this->checkFolderIsWritable(Webtrees::DATA_DIR)) { 180 $data['errors']->push( 181 '<code>' . e(realpath(Webtrees::DATA_DIR)) . '</code><br>' . 182 I18N::translate('Oops! webtrees was unable to create files in this folder.') . ' ' . 183 I18N::translate('This usually means that you need to change the folder permissions to 777.') 184 ); 185 } 186 187 switch ($step) { 188 default: 189 case 1: 190 return $this->step1Language($data); 191 case 2: 192 return $this->step2CheckServer($data); 193 case 3: 194 return $this->step3DatabaseType($data); 195 case 4: 196 return $this->step4DatabaseConnection($data); 197 case 5: 198 return $this->step5Administrator($data); 199 case 6: 200 return $this->step6Install($data); 201 } 202 } 203 204 /** 205 * @param ServerRequestInterface $request 206 * 207 * @return array<string,mixed> 208 */ 209 private function userData(ServerRequestInterface $request): array 210 { 211 $params = (array) $request->getParsedBody(); 212 213 $data = []; 214 215 foreach (self::DEFAULT_DATA as $key => $default) { 216 $data[$key] = $params[$key] ?? $default; 217 } 218 219 return $data; 220 } 221 222 /** 223 * The server's memory limit 224 * 225 * @return int 226 */ 227 private function maxExecutionTime(): int 228 { 229 return (int) ini_get('max_execution_time'); 230 } 231 232 /** 233 * The server's memory limit (in MB). 234 * 235 * @return int 236 */ 237 private function memoryLimit(): int 238 { 239 $memory_limit = ini_get('memory_limit'); 240 241 $number = (int) $memory_limit; 242 243 switch (substr($memory_limit, -1)) { 244 case 'g': 245 case 'G': 246 return $number * 1024; 247 case 'm': 248 case 'M': 249 return $number; 250 case 'k': 251 case 'K': 252 return (int) ($number / 1024); 253 default: 254 return (int) ($number / 1048576); 255 } 256 } 257 258 /** 259 * Check we can write to the data folder. 260 * 261 * @param string $data_dir 262 * 263 * @return bool 264 */ 265 private function checkFolderIsWritable(string $data_dir): bool 266 { 267 $text1 = random_bytes(32); 268 269 try { 270 file_put_contents($data_dir . 'test.txt', $text1); 271 $text2 = file_get_contents(Webtrees::DATA_DIR . 'test.txt'); 272 unlink(Webtrees::DATA_DIR . 'test.txt'); 273 } catch (Exception $ex) { 274 return false; 275 } 276 277 return $text1 === $text2; 278 } 279 280 /** 281 * @param array<string,mixed> $data 282 * 283 * @return ResponseInterface 284 */ 285 private function step1Language(array $data): ResponseInterface 286 { 287 return $this->viewResponse('setup/step-1-language', $data); 288 } 289 290 /** 291 * @param array<string,mixed> $data 292 * 293 * @return ResponseInterface 294 */ 295 private function step2CheckServer(array $data): ResponseInterface 296 { 297 return $this->viewResponse('setup/step-2-server-checks', $data); 298 } 299 300 /** 301 * @param array<string,mixed> $data 302 * 303 * @return ResponseInterface 304 */ 305 private function step3DatabaseType(array $data): ResponseInterface 306 { 307 if ($data['errors']->isNotEmpty()) { 308 return $this->viewResponse('setup/step-2-server-checks', $data); 309 } 310 311 return $this->viewResponse('setup/step-3-database-type', $data); 312 } 313 314 /** 315 * @param array<string,mixed> $data 316 * 317 * @return ResponseInterface 318 */ 319 private function step4DatabaseConnection(array $data): ResponseInterface 320 { 321 if ($data['errors']->isNotEmpty()) { 322 return $this->step3DatabaseType($data); 323 } 324 325 return $this->viewResponse('setup/step-4-database-' . $data['dbtype'], $data); 326 } 327 328 /** 329 * @param array<string,mixed> $data 330 * 331 * @return ResponseInterface 332 */ 333 private function step5Administrator(array $data): ResponseInterface 334 { 335 // Use default port, if none specified. 336 $data['dbport'] = $data['dbport'] ?: self::DEFAULT_PORTS[$data['dbtype']]; 337 338 try { 339 $this->connectToDatabase($data); 340 } catch (Throwable $ex) { 341 $data['errors']->push($ex->getMessage()); 342 343 // Don't jump to step 4, as the error will make it jump to step 3. 344 return $this->viewResponse('setup/step-4-database-' . $data['dbtype'], $data); 345 } 346 347 return $this->viewResponse('setup/step-5-administrator', $data); 348 } 349 350 /** 351 * @param array<string,mixed> $data 352 * 353 * @return ResponseInterface 354 */ 355 private function step6Install(array $data): ResponseInterface 356 { 357 $error = $this->checkAdminUser($data['wtname'], $data['wtuser'], $data['wtpass'], $data['wtemail']); 358 359 if ($error !== '') { 360 $data['errors']->push($error); 361 362 return $this->step5Administrator($data); 363 } 364 365 try { 366 $this->createConfigFile($data); 367 } catch (Throwable $exception) { 368 return $this->viewResponse('setup/step-6-failed', ['exception' => $exception]); 369 } 370 371 // Done - start using webtrees! 372 return redirect($data['baseurl']); 373 } 374 375 /** 376 * @param string $wtname 377 * @param string $wtuser 378 * @param string $wtpass 379 * @param string $wtemail 380 * 381 * @return string 382 */ 383 private function checkAdminUser(string $wtname, string $wtuser, string $wtpass, string $wtemail): string 384 { 385 if ($wtname === '' || $wtuser === '' || $wtpass === '' || $wtemail === '') { 386 return I18N::translate('You must enter all the administrator account fields.'); 387 } 388 389 if (mb_strlen($wtpass) < 6) { 390 return I18N::translate('The password needs to be at least six characters long.'); 391 } 392 393 return ''; 394 } 395 396 /** 397 * @param array<string,mixed> $data 398 * 399 * @return void 400 */ 401 private function createConfigFile(array $data): void 402 { 403 // Create/update the database tables. 404 $this->connectToDatabase($data); 405 $this->migration_service->updateSchema('\Fisharebest\Webtrees\Schema', 'WT_SCHEMA_VERSION', Webtrees::SCHEMA_VERSION); 406 407 // Add some default/necessary configuration data. 408 $this->migration_service->seedDatabase(); 409 410 // If we are re-installing, then this user may already exist. 411 $admin = $this->user_service->findByIdentifier($data['wtemail']); 412 if ($admin === null) { 413 $admin = $this->user_service->findByIdentifier($data['wtuser']); 414 } 415 // Create the user 416 if ($admin === null) { 417 $admin = $this->user_service->create($data['wtuser'], $data['wtname'], $data['wtemail'], $data['wtpass']); 418 $admin->setPreference(UserInterface::PREF_LANGUAGE, $data['lang']); 419 $admin->setPreference(UserInterface::PREF_IS_VISIBLE_ONLINE, '1'); 420 } else { 421 $admin->setPassword($_POST['wtpass']); 422 } 423 // Make the user an administrator 424 $admin->setPreference(UserInterface::PREF_IS_ADMINISTRATOR, '1'); 425 $admin->setPreference(UserInterface::PREF_IS_EMAIL_VERIFIED, '1'); 426 $admin->setPreference(UserInterface::PREF_IS_ACCOUNT_APPROVED, '1'); 427 428 // Write the config file. We already checked that this would work. 429 $config_ini_php = view('setup/config.ini', $data); 430 431 file_put_contents(Webtrees::CONFIG_FILE, $config_ini_php); 432 433 // Login as the new user 434 $request = app(ServerRequestInterface::class) 435 ->withAttribute('base_url', $data['baseurl']); 436 437 Session::start($request); 438 Auth::login($admin); 439 Session::put('language', $data['lang']); 440 } 441 442 /** 443 * @param array<string,mixed> $data 444 * 445 * @return void 446 */ 447 private function connectToDatabase(array $data): void 448 { 449 $capsule = new DB(); 450 451 // Try to create the database, if it does not already exist. 452 switch ($data['dbtype']) { 453 case 'sqlite': 454 $data['dbname'] = Webtrees::ROOT_DIR . 'data/' . $data['dbname'] . '.sqlite'; 455 touch($data['dbname']); 456 break; 457 458 case 'mysql': 459 $capsule->addConnection([ 460 'driver' => $data['dbtype'], 461 'host' => $data['dbhost'], 462 'port' => $data['dbport'], 463 'database' => '', 464 'username' => $data['dbuser'], 465 'password' => $data['dbpass'], 466 ], 'temp'); 467 $capsule->getConnection('temp')->statement('CREATE DATABASE IF NOT EXISTS `' . $data['dbname'] . '` COLLATE utf8_unicode_ci'); 468 break; 469 } 470 471 // Connect to the database. 472 $capsule->addConnection([ 473 'driver' => $data['dbtype'], 474 'host' => $data['dbhost'], 475 'port' => $data['dbport'], 476 'database' => $data['dbname'], 477 'username' => $data['dbuser'], 478 'password' => $data['dbpass'], 479 'prefix' => $data['tblpfx'], 480 'prefix_indexes' => true, 481 // For MySQL 482 'charset' => 'utf8', 483 'collation' => 'utf8_unicode_ci', 484 'timezone' => '+00:00', 485 'engine' => 'InnoDB', 486 'modes' => [ 487 'ANSI', 488 'STRICT_TRANS_TABLES', 489 'NO_ZERO_IN_DATE', 490 'NO_ZERO_DATE', 491 'ERROR_FOR_DIVISION_BY_ZERO', 492 ], 493 // For SQLite 494 'foreign_key_constraints' => true, 495 ]); 496 497 $capsule->setAsGlobal(); 498 } 499} 500