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