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