SetupWizard::checkFolderIsWritable()   A
last analyzed

Complexity

Conditions 2
Paths 4

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 8
nc 4
nop 1
dl 0
loc 13
rs 10
c 0
b 0
f 0
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
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\Contracts\UserInterface;
28
use Fisharebest\Webtrees\DB;
29
use Fisharebest\Webtrees\Factories\CacheFactory;
30
use Fisharebest\Webtrees\Http\ViewResponseTrait;
31
use Fisharebest\Webtrees\I18N;
32
use Fisharebest\Webtrees\Module\ModuleLanguageInterface;
33
use Fisharebest\Webtrees\Registry;
34
use Fisharebest\Webtrees\Services\MigrationService;
35
use Fisharebest\Webtrees\Services\ModuleService;
36
use Fisharebest\Webtrees\Services\ServerCheckService;
37
use Fisharebest\Webtrees\Services\UserService;
38
use Fisharebest\Webtrees\Session;
39
use Fisharebest\Webtrees\Validator;
40
use Fisharebest\Webtrees\Webtrees;
41
use Illuminate\Support\Collection;
42
use Psr\Http\Message\ResponseInterface;
43
use Psr\Http\Message\ServerRequestInterface;
44
use Psr\Http\Server\RequestHandlerInterface;
45
use Throwable;
46
47
use function e;
48
use function file_get_contents;
49
use function file_put_contents;
50
use function ini_get;
51
use function random_bytes;
52
use function realpath;
53
use function redirect;
54
use function substr;
55
use function touch;
56
use function unlink;
57
use function view;
58
59
/**
60
 * Controller for the installation wizard
61
 */
62
class 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
        'dbkey'    => '',
79
        'dbcert'   => '',
80
        'dbca'     => '',
81
        'dbverify' => '',
82
        'wtname'   => '',
83
        'wtuser'   => '',
84
        'wtpass'   => '',
85
        'wtemail'  => '',
86
    ];
87
88
    private const DEFAULT_PORTS = [
89
        DB::MYSQL      => '3306',
90
        DB::POSTGRES   => '5432',
91
        DB::SQLITE     => '',
92
        DB::SQL_SERVER => '', // Do not use default, as it is valid to have no port number.
93
    ];
94
95
    private MigrationService $migration_service;
96
97
    private ModuleService $module_service;
98
99
    private ServerCheckService $server_check_service;
100
101
    private UserService $user_service;
102
103
    /**
104
     * @param MigrationService   $migration_service
105
     * @param ModuleService      $module_service
106
     * @param ServerCheckService $server_check_service
107
     * @param UserService        $user_service
108
     */
109
    public function __construct(
110
        MigrationService $migration_service,
111
        ModuleService $module_service,
112
        ServerCheckService $server_check_service,
113
        UserService $user_service
114
    ) {
115
        $this->user_service         = $user_service;
116
        $this->migration_service    = $migration_service;
117
        $this->module_service       = $module_service;
118
        $this->server_check_service = $server_check_service;
119
    }
120
121
    /**
122
     * Installation wizard - check user input and proceed to the next step.
123
     *
124
     * @param ServerRequestInterface $request
125
     *
126
     * @return ResponseInterface
127
     */
128
    public function handle(ServerRequestInterface $request): ResponseInterface
129
    {
130
        $this->layout = 'layouts/setup';
131
132
        // Some functions need a cache, but we don't have one yet.
133
        Registry::cache(new CacheFactory());
134
135
        // We will need an IP address for the logs.
136
        $ip_address = Validator::serverParams($request)->string('REMOTE_ADDR', '127.0.0.1');
137
        $request    = $request->withAttribute('client-ip', $ip_address);
138
139
        Registry::container()->set(ServerRequestInterface::class, $request);
140
141
        $data = $this->userData($request);
142
143
        $step = Validator::parsedBody($request)->integer('step', 1);
144
145
        $locales = $this->module_service
146
            ->setupLanguages()
147
            ->map(static fn (ModuleLanguageInterface $module): LocaleInterface => $module->locale());
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
        $data = [];
205
206
        foreach (self::DEFAULT_DATA as $key => $default) {
207
            $data[$key] = Validator::parsedBody($request)->string($key, $default);
208
        }
209
210
        return $data;
211
    }
212
213
    /**
214
     * The server's memory limit
215
     *
216
     * @return int
217
     */
218
    private function maxExecutionTime(): int
219
    {
220
        return (int) ini_get('max_execution_time');
221
    }
222
223
    /**
224
     * The server's memory limit (in MB).
225
     *
226
     * @return int
227
     */
228
    private function memoryLimit(): int
229
    {
230
        $memory_limit = ini_get('memory_limit');
231
232
        $number = (int) $memory_limit;
233
234
        switch (substr($memory_limit, -1)) {
235
            case 'g':
236
            case 'G':
237
                return $number * 1024;
238
            case 'm':
239
            case 'M':
240
                return $number;
241
            case 'k':
242
            case 'K':
243
                return (int) ($number / 1024);
244
            default:
245
                return (int) ($number / 1048576);
246
        }
247
    }
248
249
    /**
250
     * Check we can write to the data folder.
251
     *
252
     * @param string $data_dir
253
     *
254
     * @return bool
255
     */
256
    private function checkFolderIsWritable(string $data_dir): bool
257
    {
258
        $text1 = random_bytes(32);
259
260
        try {
261
            file_put_contents($data_dir . 'test.txt', $text1);
262
            $text2 = file_get_contents(Webtrees::DATA_DIR . 'test.txt');
263
            unlink(Webtrees::DATA_DIR . 'test.txt');
264
        } catch (Exception) {
265
            return false;
266
        }
267
268
        return $text1 === $text2;
269
    }
270
271
    /**
272
     * @param array<string,mixed> $data
273
     *
274
     * @return ResponseInterface
275
     */
276
    private function step1Language(array $data): ResponseInterface
277
    {
278
        return $this->viewResponse('setup/step-1-language', $data);
279
    }
280
281
    /**
282
     * @param array<string,mixed> $data
283
     *
284
     * @return ResponseInterface
285
     */
286
    private function step2CheckServer(array $data): ResponseInterface
287
    {
288
        return $this->viewResponse('setup/step-2-server-checks', $data);
289
    }
290
291
    /**
292
     * @param array<string,mixed> $data
293
     *
294
     * @return ResponseInterface
295
     */
296
    private function step3DatabaseType(array $data): ResponseInterface
297
    {
298
        if ($data['errors']->isNotEmpty()) {
299
            return $this->viewResponse('setup/step-2-server-checks', $data);
300
        }
301
302
        return $this->viewResponse('setup/step-3-database-type', $data);
303
    }
304
305
    /**
306
     * @param array<string,mixed> $data
307
     *
308
     * @return ResponseInterface
309
     */
310
    private function step4DatabaseConnection(array $data): ResponseInterface
311
    {
312
        if ($data['errors']->isNotEmpty()) {
313
            return $this->step3DatabaseType($data);
314
        }
315
316
        return $this->viewResponse('setup/step-4-database-' . $data['dbtype'], $data);
317
    }
318
319
    /**
320
     * @param array<string,mixed> $data
321
     *
322
     * @return ResponseInterface
323
     */
324
    private function step5Administrator(array $data): ResponseInterface
325
    {
326
        // Use default port, if none specified.
327
        $data['dbport'] = $data['dbport'] ?: self::DEFAULT_PORTS[$data['dbtype']];
328
329
        try {
330
            $this->connectToDatabase($data);
331
        } catch (Throwable $ex) {
332
            $data['errors']->push($ex->getMessage());
333
334
            // Don't jump to step 4, as the error will make it jump to step 3.
335
            return $this->viewResponse('setup/step-4-database-' . $data['dbtype'], $data);
336
        }
337
338
        return $this->viewResponse('setup/step-5-administrator', $data);
339
    }
340
341
    /**
342
     * @param array<string,mixed> $data
343
     *
344
     * @return ResponseInterface
345
     */
346
    private function step6Install(array $data): ResponseInterface
347
    {
348
        $error = $this->checkAdminUser($data['wtname'], $data['wtuser'], $data['wtpass'], $data['wtemail']);
349
350
        if ($error !== '') {
351
            $data['errors']->push($error);
352
353
            return $this->step5Administrator($data);
354
        }
355
356
        try {
357
            $this->createConfigFile($data);
358
        } catch (Throwable $exception) {
359
            return $this->viewResponse('setup/step-6-failed', ['exception' => $exception]);
360
        }
361
362
        // Done - start using webtrees!
363
        return redirect($data['baseurl']);
364
    }
365
366
    /**
367
     * @param string $wtname
368
     * @param string $wtuser
369
     * @param string $wtpass
370
     * @param string $wtemail
371
     *
372
     * @return string
373
     */
374
    private function checkAdminUser(string $wtname, string $wtuser, string $wtpass, string $wtemail): string
375
    {
376
        if ($wtname === '' || $wtuser === '' || $wtpass === '' || $wtemail === '') {
377
            return I18N::translate('You must enter all the administrator account fields.');
378
        }
379
380
        if (mb_strlen($wtpass) < 6) {
381
            return I18N::translate('The password needs to be at least six characters long.');
382
        }
383
384
        return '';
385
    }
386
387
    /**
388
     * @param array<string,mixed> $data
389
     *
390
     * @return void
391
     */
392
    private function createConfigFile(array $data): void
393
    {
394
        // Create/update the database tables.
395
        $this->connectToDatabase($data);
396
        $this->migration_service->updateSchema('\Fisharebest\Webtrees\Schema', 'WT_SCHEMA_VERSION', Webtrees::SCHEMA_VERSION);
397
398
        // Add some default/necessary configuration data.
399
        $this->migration_service->seedDatabase();
400
401
        // If we are re-installing, then this user may already exist.
402
        $admin = $this->user_service->findByIdentifier($data['wtemail']);
403
        if ($admin === null) {
404
            $admin = $this->user_service->findByIdentifier($data['wtuser']);
405
        }
406
        // Create the user
407
        if ($admin === null) {
408
            $admin = $this->user_service->create($data['wtuser'], $data['wtname'], $data['wtemail'], $data['wtpass']);
409
            $admin->setPreference(UserInterface::PREF_LANGUAGE, $data['lang']);
410
            $admin->setPreference(UserInterface::PREF_IS_VISIBLE_ONLINE, '1');
411
        } else {
412
            $admin->setPassword($_POST['wtpass']);
413
        }
414
        // Make the user an administrator
415
        $admin->setPreference(UserInterface::PREF_IS_ADMINISTRATOR, '1');
416
        $admin->setPreference(UserInterface::PREF_IS_EMAIL_VERIFIED, '1');
417
        $admin->setPreference(UserInterface::PREF_IS_ACCOUNT_APPROVED, '1');
418
419
        // Write the config file. We already checked that this would work.
420
        $config_ini_php = view('setup/config.ini', $data);
421
422
        file_put_contents(Webtrees::CONFIG_FILE, $config_ini_php);
423
424
        // Login as the new user
425
        $request = Registry::container()->get(ServerRequestInterface::class)
426
            ->withAttribute('base_url', $data['baseurl']);
427
428
        Session::start($request);
429
        Auth::login($admin);
430
        Session::put('language', $data['lang']);
431
    }
432
433
    /**
434
     * @param array<string,mixed> $data
435
     *
436
     * @return void
437
     */
438
    private function connectToDatabase(array $data): void
439
    {
440
        // Try to create the database, if it does not already exist.
441
        switch ($data['dbtype']) {
442
            case DB::SQLITE:
443
                touch(Webtrees::ROOT_DIR . 'data/' . $data['dbname'] . '.sqlite');
444
                break;
445
446
            case DB::MYSQL:
447
                DB::connect(
448
                    driver: $data['dbtype'],
449
                    host: $data['dbhost'],
450
                    port: $data['dbport'],
451
                    database: '',
452
                    username: $data['dbuser'],
453
                    password: $data['dbpass'],
454
                    prefix: $data['tblpfx'],
455
                    key: $data['dbkey'],
456
                    certificate: $data['dbcert'],
457
                    ca: $data['dbca'],
458
                    verify_certificate: (bool) $data['dbverify'],
459
                );
460
                DB::exec('CREATE DATABASE IF NOT EXISTS `' . $data['dbname'] . '` COLLATE utf8mb4_unicode_ci');
461
                break;
462
        }
463
464
        DB::connect(
465
            driver: $data['dbtype'],
466
            host: $data['dbhost'],
467
            port: $data['dbport'],
468
            database: $data['dbname'],
469
            username: $data['dbuser'],
470
            password: $data['dbpass'],
471
            prefix: $data['tblpfx'],
472
            key: $data['dbkey'],
473
            certificate: $data['dbcert'],
474
            ca: $data['dbca'],
475
            verify_certificate: (bool) $data['dbverify'],
476
        );
477
    }
478
}
479