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