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