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