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