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