Passed
Push — master ( 573931...5c073d )
by Greg
07:45
created

SetupWizard::createConfigFile()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 39
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 3
eloc 22
nc 4
nop 1
dl 0
loc 39
rs 9.568
c 0
b 0
f 0
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
18
declare(strict_types=1);
19
20
namespace Fisharebest\Webtrees\Http\RequestHandlers;
21
22
use Exception;
23
use Fisharebest\Localization\Locale;
24
use Fisharebest\Localization\Locale\LocaleEnUs;
25
use Fisharebest\Localization\Locale\LocaleInterface;
26
use Fisharebest\Webtrees\Auth;
27
use Fisharebest\Webtrees\Cache;
28
use Fisharebest\Webtrees\Http\ViewResponseTrait;
29
use Fisharebest\Webtrees\I18N;
30
use Fisharebest\Webtrees\Module\ModuleLanguageInterface;
31
use Fisharebest\Webtrees\Services\MigrationService;
32
use Fisharebest\Webtrees\Services\ModuleService;
33
use Fisharebest\Webtrees\Services\ServerCheckService;
34
use Fisharebest\Webtrees\Services\UserService;
35
use Fisharebest\Webtrees\Session;
36
use Fisharebest\Webtrees\User;
37
use Fisharebest\Webtrees\Webtrees;
38
use Illuminate\Database\Capsule\Manager as DB;
39
use Psr\Http\Message\ResponseInterface;
40
use Psr\Http\Message\ServerRequestInterface;
41
use Psr\Http\Server\RequestHandlerInterface;
42
use Symfony\Component\Cache\Adapter\NullAdapter;
43
use Throwable;
44
45
use function app;
46
use function e;
47
use function file_get_contents;
48
use function file_put_contents;
49
use function ini_get;
50
use function random_bytes;
51
use function realpath;
52
use function redirect;
53
use function substr;
54
use function touch;
55
use function unlink;
56
use function view;
57
58
/**
59
 * Controller for the installation wizard
60
 */
61
class 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