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