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