xref: /webtrees/app/Http/RequestHandlers/SetupWizard.php (revision d16ec2e911ac2964f63c4e377e516792626f310b)
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