Issues (2564)

app/Http/RequestHandlers/SetupWizard.php (1 issue)

Labels
Severity
1
<?php
2
3
/**
4
 * webtrees: online genealogy
5
 * Copyright (C) 2025 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\PhpService;
37
use Fisharebest\Webtrees\Services\ServerCheckService;
38
use Fisharebest\Webtrees\Services\UserService;
39
use Fisharebest\Webtrees\Session;
40
use Fisharebest\Webtrees\Validator;
41
use Fisharebest\Webtrees\Webtrees;
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 intdiv;
51
use function random_bytes;
52
use function realpath;
53
use function redirect;
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 string DEFAULT_DBTYPE = DB::MYSQL;
0 ignored issues
show
A parse error occurred: Syntax error, unexpected T_STRING, expecting '=' on line 65 at column 25
Loading history...
66
    private const string DEFAULT_PREFIX = 'wt_';
67
    private const array 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
        'dbkey'    => '',
78
        'dbcert'   => '',
79
        'dbca'     => '',
80
        'dbverify' => '',
81
        'wtname'   => '',
82
        'wtuser'   => '',
83
        'wtpass'   => '',
84
        'wtemail'  => '',
85
    ];
86
87
    private const array DEFAULT_PORTS = [
88
        DB::MYSQL      => '3306',
89
        DB::POSTGRES   => '5432',
90
        DB::SQLITE     => '',
91
        DB::SQL_SERVER => '', // Do not use default, as it is valid to have no port number.
92
    ];
93
94
    public function __construct(
95
        private MigrationService $migration_service,
96
        private ModuleService $module_service,
97
        private PhpService $php_service,
98
        private ServerCheckService $server_check_service,
99
        private UserService $user_service
100
    ) {
101
    }
102
103
    /**
104
     * Installation wizard - check user input and proceed to the next step.
105
     */
106
    public function handle(ServerRequestInterface $request): ResponseInterface
107
    {
108
        $this->layout = 'layouts/setup';
109
110
        // Some functions need a cache, but we don't have one yet.
111
        Registry::cache(new CacheFactory());
112
113
        // We will need an IP address for the logs.
114
        $ip_address = Validator::serverParams($request)->string('REMOTE_ADDR', '127.0.0.1');
115
        $request    = $request->withAttribute('client-ip', $ip_address);
116
117
        Registry::container()->set(ServerRequestInterface::class, $request);
118
119
        $data = $this->userData($request);
120
121
        $step = Validator::parsedBody($request)->integer('step', 1);
122
123
        $locales = $this->module_service
124
            ->setupLanguages()
125
            ->map(static fn (ModuleLanguageInterface $module): LocaleInterface => $module->locale());
126
127
        if ($data['lang'] === '') {
128
            $default = new LocaleEnUs();
129
130
            $locale  = Locale::httpAcceptLanguage($request->getServerParams(), $locales->all(), $default);
131
132
            $data['lang'] = $locale->languageTag();
133
        }
134
135
        I18N::init($data['lang'], true);
136
137
        $data['cpu_limit']    = $this->php_service->maxExecutionTime();
138
        $data['locales']      = $locales;
139
        $data['memory_limit'] = $this->php_service->memoryLimit();
140
141
        // Only show database errors after the user has chosen a driver.
142
        if ($step >= 4) {
143
            $data['errors']   = $this->server_check_service->serverErrors($data['dbtype']);
144
            $data['warnings'] = $this->server_check_service->serverWarnings($data['dbtype']);
145
        } else {
146
            $data['errors']   = $this->server_check_service->serverErrors();
147
            $data['warnings'] = $this->server_check_service->serverWarnings();
148
        }
149
150
        if (!$this->checkFolderIsWritable(Webtrees::DATA_DIR)) {
151
            $data['errors']->push(
152
                '<code>' . e(realpath(Webtrees::DATA_DIR)) . '</code><br>' .
153
                I18N::translate('Oops! webtrees was unable to create files in this folder.') . ' ' .
154
                I18N::translate('This usually means that you need to change the folder permissions to 777.')
155
            );
156
        }
157
158
        switch ($step) {
159
            default:
160
            case 1:
161
                return $this->step1Language($data);
162
            case 2:
163
                return $this->step2CheckServer($data);
164
            case 3:
165
                return $this->step3DatabaseType($data);
166
            case 4:
167
                return $this->step4DatabaseConnection($data);
168
            case 5:
169
                return $this->step5Administrator($data);
170
            case 6:
171
                return $this->step6Install($data);
172
        }
173
    }
174
175
    /**
176
     * @return array<string,mixed>
177
     */
178
    private function userData(ServerRequestInterface $request): array
179
    {
180
        $data = [];
181
182
        foreach (self::DEFAULT_DATA as $key => $default) {
183
            $data[$key] = Validator::parsedBody($request)->string($key, $default);
184
        }
185
186
        return $data;
187
    }
188
189
    /**
190
     * Check we can write to the data folder.
191
     */
192
    private function checkFolderIsWritable(string $data_dir): bool
193
    {
194
        $text1 = random_bytes(32);
195
196
        try {
197
            file_put_contents($data_dir . 'test.txt', $text1);
198
            $text2 = file_get_contents(Webtrees::DATA_DIR . 'test.txt');
199
            unlink(Webtrees::DATA_DIR . 'test.txt');
200
        } catch (Exception) {
201
            return false;
202
        }
203
204
        return $text1 === $text2;
205
    }
206
207
    /**
208
     * @param array<string,mixed> $data
209
     */
210
    private function step1Language(array $data): ResponseInterface
211
    {
212
        return $this->viewResponse('setup/step-1-language', $data);
213
    }
214
215
    /**
216
     * @param array<string,mixed> $data
217
     */
218
    private function step2CheckServer(array $data): ResponseInterface
219
    {
220
        return $this->viewResponse('setup/step-2-server-checks', $data);
221
    }
222
223
    /**
224
     * @param array<string,mixed> $data
225
     */
226
    private function step3DatabaseType(array $data): ResponseInterface
227
    {
228
        if ($data['errors']->isNotEmpty()) {
229
            return $this->viewResponse('setup/step-2-server-checks', $data);
230
        }
231
232
        return $this->viewResponse('setup/step-3-database-type', $data);
233
    }
234
235
    /**
236
     * @param array<string,mixed> $data
237
     */
238
    private function step4DatabaseConnection(array $data): ResponseInterface
239
    {
240
        if ($data['errors']->isNotEmpty()) {
241
            return $this->step3DatabaseType($data);
242
        }
243
244
        $data['mysql_local'] = 'localhost:' . $this->php_service->iniGet(option: 'pdo_mysql.default_socket');
245
246
        return $this->viewResponse('setup/step-4-database-' . $data['dbtype'], $data);
247
    }
248
249
    /**
250
     * @param array<string,mixed> $data
251
     */
252
    private function step5Administrator(array $data): ResponseInterface
253
    {
254
        // Use default port, if none specified.
255
        $data['dbport'] = $data['dbport'] ?: self::DEFAULT_PORTS[$data['dbtype']];
256
257
        try {
258
            $this->connectToDatabase($data);
259
        } catch (Throwable $ex) {
260
            $data['errors']->push($ex->getMessage());
261
262
            // Don't jump to step 4, as the error will make it jump to step 3.
263
            $data['mysql_local'] = 'localhost:' . $this->php_service->iniGet(option: 'pdo_mysql.default_socket');
264
265
            return $this->viewResponse('setup/step-4-database-' . $data['dbtype'], $data);
266
        }
267
268
        return $this->viewResponse('setup/step-5-administrator', $data);
269
    }
270
271
    /**
272
     * @param array<string,mixed> $data
273
     */
274
    private function step6Install(array $data): ResponseInterface
275
    {
276
        $error = $this->checkAdminUser($data['wtname'], $data['wtuser'], $data['wtpass'], $data['wtemail']);
277
278
        if ($error !== '') {
279
            $data['errors']->push($error);
280
281
            return $this->step5Administrator($data);
282
        }
283
284
        try {
285
            $this->createConfigFile($data);
286
        } catch (Throwable $exception) {
287
            return $this->viewResponse('setup/step-6-failed', ['exception' => $exception]);
288
        }
289
290
        // Done - start using webtrees!
291
        return redirect($data['baseurl']);
292
    }
293
294
    private function checkAdminUser(string $wtname, string $wtuser, string $wtpass, string $wtemail): string
295
    {
296
        if ($wtname === '' || $wtuser === '' || $wtpass === '' || $wtemail === '') {
297
            return I18N::translate('You must enter all the administrator account fields.');
298
        }
299
300
        if (mb_strlen($wtpass) < 6) {
301
            return I18N::translate('The password needs to be at least six characters long.');
302
        }
303
304
        return '';
305
    }
306
307
    /**
308
     * @param array<string,mixed> $data
309
     */
310
    private function createConfigFile(array $data): void
311
    {
312
        // Create/update the database tables.
313
        $this->connectToDatabase($data);
314
        $this->migration_service->updateSchema('\Fisharebest\Webtrees\Schema', 'WT_SCHEMA_VERSION', Webtrees::SCHEMA_VERSION);
315
316
        // Add some default/necessary configuration data.
317
        $this->migration_service->seedDatabase();
318
319
        // If we are re-installing, then this user may already exist.
320
        $admin = $this->user_service->findByIdentifier($data['wtemail']);
321
        if ($admin === null) {
322
            $admin = $this->user_service->findByIdentifier($data['wtuser']);
323
        }
324
        // Create the user
325
        if ($admin === null) {
326
            $admin = $this->user_service->create($data['wtuser'], $data['wtname'], $data['wtemail'], $data['wtpass']);
327
            $admin->setPreference(UserInterface::PREF_LANGUAGE, $data['lang']);
328
            $admin->setPreference(UserInterface::PREF_IS_VISIBLE_ONLINE, '1');
329
        } else {
330
            $admin->setPassword($_POST['wtpass']);
331
        }
332
        // Make the user an administrator
333
        $admin->setPreference(UserInterface::PREF_IS_ADMINISTRATOR, '1');
334
        $admin->setPreference(UserInterface::PREF_IS_EMAIL_VERIFIED, '1');
335
        $admin->setPreference(UserInterface::PREF_IS_ACCOUNT_APPROVED, '1');
336
337
        // Write the config file. We already checked that this would work.
338
        $config_ini_php = view('setup/config.ini', $data);
339
340
        file_put_contents(Webtrees::CONFIG_FILE, $config_ini_php);
341
342
        // Login as the new user
343
        $request = Registry::container()->get(ServerRequestInterface::class)
344
            ->withAttribute('base_url', $data['baseurl']);
345
346
        Session::start($request);
347
        Auth::login($admin);
348
        Session::put('language', $data['lang']);
349
    }
350
351
    /**
352
     * @param array<string,mixed> $data
353
     */
354
    private function connectToDatabase(array $data): void
355
    {
356
        // Try to create the database, if it does not already exist.
357
        switch ($data['dbtype']) {
358
            case DB::SQLITE:
359
                touch(Webtrees::ROOT_DIR . 'data/' . $data['dbname'] . '.sqlite');
360
                break;
361
362
            case DB::MYSQL:
363
                DB::connect(
364
                    driver: $data['dbtype'],
365
                    host: $data['dbhost'],
366
                    port: $data['dbport'],
367
                    database: '',
368
                    username: $data['dbuser'],
369
                    password: $data['dbpass'],
370
                    prefix: $data['tblpfx'],
371
                    key: $data['dbkey'],
372
                    certificate: $data['dbcert'],
373
                    ca: $data['dbca'],
374
                    verify_certificate: (bool) $data['dbverify'],
375
                );
376
                DB::exec('CREATE DATABASE IF NOT EXISTS `' . $data['dbname'] . '` COLLATE utf8mb4_unicode_ci');
377
                break;
378
        }
379
380
        DB::connect(
381
            driver: $data['dbtype'],
382
            host: $data['dbhost'],
383
            port: $data['dbport'],
384
            database: $data['dbname'],
385
            username: $data['dbuser'],
386
            password: $data['dbpass'],
387
            prefix: $data['tblpfx'],
388
            key: $data['dbkey'],
389
            certificate: $data['dbcert'],
390
            ca: $data['dbca'],
391
            verify_certificate: (bool) $data['dbverify'],
392
        );
393
    }
394
}
395