.
*/
declare(strict_types=1);
namespace Fisharebest\Webtrees\Http\RequestHandlers;
use Exception;
use Fisharebest\Localization\Locale;
use Fisharebest\Localization\Locale\LocaleEnUs;
use Fisharebest\Localization\Locale\LocaleInterface;
use Fisharebest\Webtrees\Auth;
use Fisharebest\Webtrees\Contracts\UserInterface;
use Fisharebest\Webtrees\Factories\CacheFactory;
use Fisharebest\Webtrees\Http\ViewResponseTrait;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Module\ModuleLanguageInterface;
use Fisharebest\Webtrees\Registry;
use Fisharebest\Webtrees\Services\MigrationService;
use Fisharebest\Webtrees\Services\ModuleService;
use Fisharebest\Webtrees\Services\ServerCheckService;
use Fisharebest\Webtrees\Services\UserService;
use Fisharebest\Webtrees\Session;
use Fisharebest\Webtrees\Validator;
use Fisharebest\Webtrees\Webtrees;
use Illuminate\Database\Capsule\Manager as DB;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Throwable;
use function e;
use function file_get_contents;
use function file_put_contents;
use function ini_get;
use function random_bytes;
use function realpath;
use function redirect;
use function substr;
use function touch;
use function unlink;
use function view;
/**
* Controller for the installation wizard
*/
class SetupWizard implements RequestHandlerInterface
{
use ViewResponseTrait;
private const DEFAULT_DBTYPE = 'mysql';
private const DEFAULT_PREFIX = 'wt_';
private const DEFAULT_DATA = [
'baseurl' => '',
'lang' => '',
'dbtype' => self::DEFAULT_DBTYPE,
'dbhost' => '',
'dbport' => '',
'dbuser' => '',
'dbpass' => '',
'dbname' => '',
'tblpfx' => self::DEFAULT_PREFIX,
'wtname' => '',
'wtuser' => '',
'wtpass' => '',
'wtemail' => '',
];
private const DEFAULT_PORTS = [
'mysql' => '3306',
'pgsql' => '5432',
'sqlite' => '',
'sqlsrv' => '', // Do not use default, as it is valid to have no port number.
];
private MigrationService $migration_service;
private ModuleService $module_service;
private ServerCheckService $server_check_service;
private UserService $user_service;
/**
* SetupWizard constructor.
*
* @param MigrationService $migration_service
* @param ModuleService $module_service
* @param ServerCheckService $server_check_service
* @param UserService $user_service
*/
public function __construct(
MigrationService $migration_service,
ModuleService $module_service,
ServerCheckService $server_check_service,
UserService $user_service
) {
$this->user_service = $user_service;
$this->migration_service = $migration_service;
$this->module_service = $module_service;
$this->server_check_service = $server_check_service;
}
/**
* Installation wizard - check user input and proceed to the next step.
*
* @param ServerRequestInterface $request
*
* @return ResponseInterface
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$this->layout = 'layouts/setup';
// Some functions need a cache, but we don't have one yet.
Registry::cache(new CacheFactory());
// We will need an IP address for the logs.
$ip_address = Validator::serverParams($request)->string('REMOTE_ADDR', '127.0.0.1');
$request = $request->withAttribute('client-ip', $ip_address);
Registry::container()->set(ServerRequestInterface::class, $request);
$data = $this->userData($request);
$step = Validator::parsedBody($request)->integer('step', 1);
$locales = $this->module_service
->setupLanguages()
->map(static function (ModuleLanguageInterface $module): LocaleInterface {
return $module->locale();
});
if ($data['lang'] === '') {
$default = new LocaleEnUs();
$locale = Locale::httpAcceptLanguage($request->getServerParams(), $locales->all(), $default);
$data['lang'] = $locale->languageTag();
}
I18N::init($data['lang'], true);
$data['cpu_limit'] = $this->maxExecutionTime();
$data['locales'] = $locales;
$data['memory_limit'] = $this->memoryLimit();
// Only show database errors after the user has chosen a driver.
if ($step >= 4) {
$data['errors'] = $this->server_check_service->serverErrors($data['dbtype']);
$data['warnings'] = $this->server_check_service->serverWarnings($data['dbtype']);
} else {
$data['errors'] = $this->server_check_service->serverErrors();
$data['warnings'] = $this->server_check_service->serverWarnings();
}
if (!$this->checkFolderIsWritable(Webtrees::DATA_DIR)) {
$data['errors']->push(
'' . e(realpath(Webtrees::DATA_DIR)) . '
' .
I18N::translate('Oops! webtrees was unable to create files in this folder.') . ' ' .
I18N::translate('This usually means that you need to change the folder permissions to 777.')
);
}
switch ($step) {
default:
case 1:
return $this->step1Language($data);
case 2:
return $this->step2CheckServer($data);
case 3:
return $this->step3DatabaseType($data);
case 4:
return $this->step4DatabaseConnection($data);
case 5:
return $this->step5Administrator($data);
case 6:
return $this->step6Install($data);
}
}
/**
* @param ServerRequestInterface $request
*
* @return array
*/
private function userData(ServerRequestInterface $request): array
{
$data = [];
foreach (self::DEFAULT_DATA as $key => $default) {
$data[$key] = Validator::parsedBody($request)->string($key, $default);
}
return $data;
}
/**
* The server's memory limit
*
* @return int
*/
private function maxExecutionTime(): int
{
return (int) ini_get('max_execution_time');
}
/**
* The server's memory limit (in MB).
*
* @return int
*/
private function memoryLimit(): int
{
$memory_limit = ini_get('memory_limit');
$number = (int) $memory_limit;
switch (substr($memory_limit, -1)) {
case 'g':
case 'G':
return $number * 1024;
case 'm':
case 'M':
return $number;
case 'k':
case 'K':
return (int) ($number / 1024);
default:
return (int) ($number / 1048576);
}
}
/**
* Check we can write to the data folder.
*
* @param string $data_dir
*
* @return bool
*/
private function checkFolderIsWritable(string $data_dir): bool
{
$text1 = random_bytes(32);
try {
file_put_contents($data_dir . 'test.txt', $text1);
$text2 = file_get_contents(Webtrees::DATA_DIR . 'test.txt');
unlink(Webtrees::DATA_DIR . 'test.txt');
} catch (Exception) {
return false;
}
return $text1 === $text2;
}
/**
* @param array $data
*
* @return ResponseInterface
*/
private function step1Language(array $data): ResponseInterface
{
return $this->viewResponse('setup/step-1-language', $data);
}
/**
* @param array $data
*
* @return ResponseInterface
*/
private function step2CheckServer(array $data): ResponseInterface
{
return $this->viewResponse('setup/step-2-server-checks', $data);
}
/**
* @param array $data
*
* @return ResponseInterface
*/
private function step3DatabaseType(array $data): ResponseInterface
{
if ($data['errors']->isNotEmpty()) {
return $this->viewResponse('setup/step-2-server-checks', $data);
}
return $this->viewResponse('setup/step-3-database-type', $data);
}
/**
* @param array $data
*
* @return ResponseInterface
*/
private function step4DatabaseConnection(array $data): ResponseInterface
{
if ($data['errors']->isNotEmpty()) {
return $this->step3DatabaseType($data);
}
return $this->viewResponse('setup/step-4-database-' . $data['dbtype'], $data);
}
/**
* @param array $data
*
* @return ResponseInterface
*/
private function step5Administrator(array $data): ResponseInterface
{
// Use default port, if none specified.
$data['dbport'] = $data['dbport'] ?: self::DEFAULT_PORTS[$data['dbtype']];
try {
$this->connectToDatabase($data);
} catch (Throwable $ex) {
$data['errors']->push($ex->getMessage());
// Don't jump to step 4, as the error will make it jump to step 3.
return $this->viewResponse('setup/step-4-database-' . $data['dbtype'], $data);
}
return $this->viewResponse('setup/step-5-administrator', $data);
}
/**
* @param array $data
*
* @return ResponseInterface
*/
private function step6Install(array $data): ResponseInterface
{
$error = $this->checkAdminUser($data['wtname'], $data['wtuser'], $data['wtpass'], $data['wtemail']);
if ($error !== '') {
$data['errors']->push($error);
return $this->step5Administrator($data);
}
try {
$this->createConfigFile($data);
} catch (Throwable $exception) {
return $this->viewResponse('setup/step-6-failed', ['exception' => $exception]);
}
// Done - start using webtrees!
return redirect($data['baseurl']);
}
/**
* @param string $wtname
* @param string $wtuser
* @param string $wtpass
* @param string $wtemail
*
* @return string
*/
private function checkAdminUser(string $wtname, string $wtuser, string $wtpass, string $wtemail): string
{
if ($wtname === '' || $wtuser === '' || $wtpass === '' || $wtemail === '') {
return I18N::translate('You must enter all the administrator account fields.');
}
if (mb_strlen($wtpass) < 6) {
return I18N::translate('The password needs to be at least six characters long.');
}
return '';
}
/**
* @param array $data
*
* @return void
*/
private function createConfigFile(array $data): void
{
// Create/update the database tables.
$this->connectToDatabase($data);
$this->migration_service->updateSchema('\Fisharebest\Webtrees\Schema', 'WT_SCHEMA_VERSION', Webtrees::SCHEMA_VERSION);
// Add some default/necessary configuration data.
$this->migration_service->seedDatabase();
// If we are re-installing, then this user may already exist.
$admin = $this->user_service->findByIdentifier($data['wtemail']);
if ($admin === null) {
$admin = $this->user_service->findByIdentifier($data['wtuser']);
}
// Create the user
if ($admin === null) {
$admin = $this->user_service->create($data['wtuser'], $data['wtname'], $data['wtemail'], $data['wtpass']);
$admin->setPreference(UserInterface::PREF_LANGUAGE, $data['lang']);
$admin->setPreference(UserInterface::PREF_IS_VISIBLE_ONLINE, '1');
} else {
$admin->setPassword($_POST['wtpass']);
}
// Make the user an administrator
$admin->setPreference(UserInterface::PREF_IS_ADMINISTRATOR, '1');
$admin->setPreference(UserInterface::PREF_IS_EMAIL_VERIFIED, '1');
$admin->setPreference(UserInterface::PREF_IS_ACCOUNT_APPROVED, '1');
// Write the config file. We already checked that this would work.
$config_ini_php = view('setup/config.ini', $data);
file_put_contents(Webtrees::CONFIG_FILE, $config_ini_php);
// Login as the new user
$request = Registry::container()->get(ServerRequestInterface::class)
->withAttribute('base_url', $data['baseurl']);
Session::start($request);
Auth::login($admin);
Session::put('language', $data['lang']);
}
/**
* @param array $data
*
* @return void
*/
private function connectToDatabase(array $data): void
{
$capsule = new DB();
// Try to create the database, if it does not already exist.
switch ($data['dbtype']) {
case 'sqlite':
$data['dbname'] = Webtrees::ROOT_DIR . 'data/' . $data['dbname'] . '.sqlite';
touch($data['dbname']);
break;
case 'mysql':
$capsule->addConnection([
'driver' => $data['dbtype'],
'host' => $data['dbhost'],
'port' => $data['dbport'],
'database' => '',
'username' => $data['dbuser'],
'password' => $data['dbpass'],
], 'temp');
$capsule->getConnection('temp')->statement('CREATE DATABASE IF NOT EXISTS `' . $data['dbname'] . '` COLLATE utf8_unicode_ci');
break;
}
// Connect to the database.
$capsule->addConnection([
'driver' => $data['dbtype'],
'host' => $data['dbhost'],
'port' => $data['dbport'],
'database' => $data['dbname'],
'username' => $data['dbuser'],
'password' => $data['dbpass'],
'prefix' => $data['tblpfx'],
'prefix_indexes' => true,
// For MySQL
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'timezone' => '+00:00',
'engine' => 'InnoDB',
'modes' => [
'ANSI',
'STRICT_TRANS_TABLES',
'NO_ZERO_IN_DATE',
'NO_ZERO_DATE',
'ERROR_FOR_DIVISION_BY_ZERO',
],
// For SQLite
'foreign_key_constraints' => true,
]);
$capsule->setAsGlobal();
}
}