Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.19% covered (warning)
65.19%
103 / 158
44.44% covered (danger)
44.44%
4 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
InstallationInputValidator
65.19% covered (warning)
65.19%
103 / 158
44.44% covered (danger)
44.44%
4 / 9
211.85
0.00% covered (danger)
0.00%
0 / 1
 validate
91.43% covered (success)
91.43%
32 / 35
0.00% covered (danger)
0.00%
0 / 1
4.01
 validateDatabaseInput
82.00% covered (success)
82.00%
41 / 50
0.00% covered (danger)
0.00%
0 / 1
33.90
 isLdapEnabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 validateLdapInput
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 isElasticsearchEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateElasticsearchInput
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 isOpenSearchEnabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateOpenSearchInput
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 validateUserCredentials
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
13
1<?php
2
3/**
4 * Validates and parses installation input from POST data or a setup array.
5 *
6 * Extracts the ~280 lines of input parsing/validation from Installer::startInstall()
7 * into a dedicated validator.
8 *
9 * This Source Code Form is subject to the terms of the Mozilla Public License,
10 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
11 * obtain one at https://mozilla.org/MPL/2.0/.
12 *
13 * @package   phpMyFAQ
14 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
15 * @copyright 2026 phpMyFAQ Team
16 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
17 * @link      https://www.phpmyfaq.de
18 * @since     2026-01-31
19 */
20
21declare(strict_types=1);
22
23namespace phpMyFAQ\Setup;
24
25use phpMyFAQ\Core\Exception;
26use phpMyFAQ\Database;
27use phpMyFAQ\Filter;
28use phpMyFAQ\System;
29
30class InstallationInputValidator
31{
32    /**
33     * Validates and parses installation input, returning a value object.
34     *
35     * @param array<string, mixed>|null $setup Optional setup array (for programmatic installs)
36     * @throws Exception
37     */
38    public function validate(?array $setup = null): InstallationInput
39    {
40        $dbSetup = $this->validateDatabaseInput($setup);
41        $ldapSetup = [];
42        $esSetup = [];
43        $osSetup = [];
44
45        $ldapEnabled = $this->isLdapEnabled();
46        if ($ldapEnabled) {
47            $ldapSetup = $this->validateLdapInput();
48        }
49
50        $esEnabled = $this->isElasticsearchEnabled();
51        if ($esEnabled) {
52            $esSetup = $this->validateElasticsearchInput();
53        }
54
55        $osEnabled = $this->isOpenSearchEnabled();
56        if ($osEnabled) {
57            $osSetup = $this->validateOpenSearchInput();
58        }
59
60        [$loginName, $password] = $this->validateUserCredentials($setup);
61
62        $language = Filter::filterInput(INPUT_POST, 'language', FILTER_SANITIZE_SPECIAL_CHARS, 'en');
63        $realname = Filter::filterInput(INPUT_POST, 'realname', FILTER_SANITIZE_SPECIAL_CHARS, '');
64        $email = Filter::filterInput(INPUT_POST, 'email', FILTER_SANITIZE_EMAIL, '');
65        $permLevel = Filter::filterInput(INPUT_POST, 'permLevel', FILTER_SANITIZE_SPECIAL_CHARS, 'basic');
66        $rootDir = $setup['rootDir'] ?? PMF_ROOT_DIR;
67
68        return new InstallationInput(
69            dbSetup: $dbSetup,
70            ldapSetup: $ldapSetup,
71            esSetup: $esSetup,
72            osSetup: $osSetup,
73            loginName: $loginName,
74            password: $password,
75            language: (string) $language,
76            realname: (string) $realname,
77            email: (string) $email,
78            permLevel: (string) $permLevel,
79            rootDir: (string) $rootDir,
80            ldapEnabled: $ldapEnabled,
81            esEnabled: $esEnabled,
82            osEnabled: $osEnabled,
83        );
84    }
85
86    /**
87     * @param array<string, mixed>|null $setup
88     * @return array<string, mixed>
89     * @throws Exception
90     */
91    private function validateDatabaseInput(?array $setup): array
92    {
93        $dbSetup = [];
94
95        $dbSetup['dbPrefix'] = (string) Filter::filterInput(INPUT_POST, 'sqltblpre', FILTER_SANITIZE_SPECIAL_CHARS, '');
96        if ('' !== $dbSetup['dbPrefix']) {
97            Database::setTablePrefix($dbSetup['dbPrefix']);
98        }
99
100        $dbSetup['dbType'] = $setup === null || !array_key_exists('dbType', $setup)
101            ? Filter::filterInput(INPUT_POST, 'sql_type', FILTER_SANITIZE_SPECIAL_CHARS)
102            : $setup['dbType'];
103
104        if (is_null($dbSetup['dbType'])) {
105            throw new Exception('Installation Error: Please select a database type.');
106        }
107        $dbSetup['dbType'] = trim((string) $dbSetup['dbType']);
108        $dataBaseFile = str_starts_with($dbSetup['dbType'], 'pdo_')
109            ? 'Pdo' . ucfirst(substr($dbSetup['dbType'], offset: 4))
110            : ucfirst($dbSetup['dbType']);
111
112        if (!file_exists(PMF_SRC_DIR . '/phpMyFAQ/Instance/Database/' . $dataBaseFile . '.php')) {
113            throw new Exception(sprintf('Installation Error: Invalid server type "%s"', $dbSetup['dbType']));
114        }
115
116        $dbSetup['dbServer'] = $setup === null || !array_key_exists('dbServer', $setup)
117            ? Filter::filterInput(INPUT_POST, 'sql_server', FILTER_SANITIZE_SPECIAL_CHARS, '')
118            : $setup['dbServer'];
119        if (trim((string) $dbSetup['dbServer']) === '' && !System::isSqlite($dbSetup['dbType'])) {
120            throw new Exception('Installation Error: Please add a database server.');
121        }
122
123        $dbSetup['dbPort'] = $setup === null || !array_key_exists('dbPort', $setup)
124            ? Filter::filterInput(INPUT_POST, 'sql_port', FILTER_VALIDATE_INT)
125            : $setup['dbPort'];
126
127        if (is_null($dbSetup['dbPort']) && !System::isSqlite($dbSetup['dbType'])) {
128            throw new Exception('Installation Error: Please add a valid database port.');
129        }
130
131        $dbSetup['dbUser'] = $setup === null || !array_key_exists('dbUser', $setup)
132            ? Filter::filterInput(INPUT_POST, 'sql_user', FILTER_SANITIZE_SPECIAL_CHARS, '')
133            : $setup['dbUser'];
134        if (trim((string) $dbSetup['dbUser']) === '' && !System::isSqlite($dbSetup['dbType'])) {
135            throw new Exception('Installation Error: Please add a database username.');
136        }
137
138        $dbSetup['dbPassword'] = $setup === null || !array_key_exists('dbPassword', $setup)
139            ? Filter::filterInput(INPUT_POST, 'sql_password', FILTER_SANITIZE_SPECIAL_CHARS, '')
140            : $setup['dbPassword'];
141        if (is_null($dbSetup['dbPassword']) && !System::isSqlite($dbSetup['dbType'])) {
142            $dbSetup['dbPassword'] = '';
143        }
144
145        $dbSetup['dbDatabaseName'] = $setup === null || !array_key_exists('dbDatabaseName', $setup)
146            ? Filter::filterInput(INPUT_POST, 'sql_db', FILTER_SANITIZE_SPECIAL_CHARS)
147            : $setup['dbDatabaseName'];
148
149        if (is_null($dbSetup['dbDatabaseName']) && !System::isSqlite($dbSetup['dbType'])) {
150            throw new Exception('Installation Error: Please add a database name.');
151        }
152
153        if (System::isSqlite($dbSetup['dbType'])) {
154            $dbSetup['dbServer'] = Filter::filterInput(
155                INPUT_POST,
156                'sql_sqlitefile',
157                FILTER_SANITIZE_SPECIAL_CHARS,
158                $setup['dbServer'] ?? null,
159            );
160            if (is_null($dbSetup['dbServer'])) {
161                throw new Exception('Installation Error: Please add a SQLite database filename.');
162            }
163        }
164
165        return $dbSetup;
166    }
167
168    private function isLdapEnabled(): bool
169    {
170        $ldapEnabled = Filter::filterInput(INPUT_POST, 'ldap_enabled', FILTER_SANITIZE_SPECIAL_CHARS);
171        return extension_loaded('ldap') && !is_null($ldapEnabled);
172    }
173
174    /**
175     * @return array<string, mixed>
176     * @throws Exception
177     */
178    private function validateLdapInput(): array
179    {
180        $ldapSetup = [];
181
182        $ldapSetup['ldapServer'] = Filter::filterInput(INPUT_POST, 'ldap_server', FILTER_SANITIZE_SPECIAL_CHARS);
183        if (is_null($ldapSetup['ldapServer'])) {
184            throw new Exception('LDAP Installation Error: Please add a LDAP server.');
185        }
186
187        $ldapSetup['ldapPort'] = Filter::filterInput(INPUT_POST, 'ldap_port', FILTER_VALIDATE_INT);
188        if (is_null($ldapSetup['ldapPort'])) {
189            throw new Exception('LDAP Installation Error: Please add a LDAP port.');
190        }
191
192        $ldapSetup['ldapBase'] = Filter::filterInput(INPUT_POST, 'ldap_base', FILTER_SANITIZE_SPECIAL_CHARS);
193        if (is_null($ldapSetup['ldapBase'])) {
194            throw new Exception('LDAP Installation Error: Please add a LDAP base search DN.');
195        }
196
197        $ldapSetup['ldapUser'] = Filter::filterInput(INPUT_POST, 'ldap_user', FILTER_SANITIZE_SPECIAL_CHARS);
198        $ldapSetup['ldapPassword'] = Filter::filterInput(INPUT_POST, 'ldap_password', FILTER_SANITIZE_SPECIAL_CHARS);
199
200        return $ldapSetup;
201    }
202
203    private function isElasticsearchEnabled(): bool
204    {
205        return !is_null(Filter::filterInput(INPUT_POST, 'elasticsearch_enabled', FILTER_SANITIZE_SPECIAL_CHARS));
206    }
207
208    /**
209     * @return array<string, mixed>
210     * @throws Exception
211     */
212    private function validateElasticsearchInput(): array
213    {
214        $esSetup = [];
215        $esHostFilter = [
216            'elasticsearch_server' => [
217                'filter' => FILTER_SANITIZE_SPECIAL_CHARS,
218                'flags' => FILTER_REQUIRE_ARRAY,
219            ],
220        ];
221
222        $esHosts = Filter::filterInputArray(INPUT_POST, $esHostFilter);
223        if (is_null($esHosts)) {
224            throw new Exception('Elasticsearch Installation Error: Please add at least one Elasticsearch host.');
225        }
226
227        $esSetup['hosts'] = $esHosts['elasticsearch_server'];
228
229        $esSetup['index'] = Filter::filterInput(INPUT_POST, 'elasticsearch_index', FILTER_SANITIZE_SPECIAL_CHARS);
230        if (is_null($esSetup['index'])) {
231            throw new Exception('Elasticsearch Installation Error: Please add an Elasticsearch index name.');
232        }
233
234        return $esSetup;
235    }
236
237    private function isOpenSearchEnabled(): bool
238    {
239        return !is_null(Filter::filterInput(INPUT_POST, 'opensearch_enabled', FILTER_SANITIZE_SPECIAL_CHARS));
240    }
241
242    /**
243     * @return array<string, mixed>
244     * @throws Exception
245     */
246    private function validateOpenSearchInput(): array
247    {
248        $osSetup = [];
249        $osHostFilter = [
250            'opensearch_server' => [
251                'filter' => FILTER_SANITIZE_SPECIAL_CHARS,
252                'flags' => FILTER_REQUIRE_ARRAY,
253            ],
254        ];
255
256        $osHosts = Filter::filterInputArray(INPUT_POST, $osHostFilter);
257        if (is_null($osHosts)) {
258            throw new Exception('OpenSearch Installation Error: Please add at least one OpenSearch host.');
259        }
260
261        $osSetup['hosts'] = $osHosts['opensearch_server'];
262
263        $osSetup['index'] = Filter::filterInput(INPUT_POST, 'opensearch_index', FILTER_SANITIZE_SPECIAL_CHARS);
264        if (is_null($osSetup['index'])) {
265            throw new Exception('OpenSearch Installation Error: Please add an OpenSearch index name.');
266        }
267
268        return $osSetup;
269    }
270
271    /**
272     * @param array<string, mixed>|null $setup
273     * @return array{string, string}
274     * @throws Exception
275     */
276    private function validateUserCredentials(?array $setup): array
277    {
278        $loginName = $setup === null || !array_key_exists('loginname', $setup)
279            ? Filter::filterInput(INPUT_POST, 'loginname', FILTER_SANITIZE_SPECIAL_CHARS)
280            : $setup['loginname'];
281
282        if (is_null($loginName)) {
283            throw new Exception('Installation Error: Please add a login name for your account.');
284        }
285
286        $password = $setup === null || !array_key_exists('password', $setup)
287            ? Filter::filterInput(INPUT_POST, 'password', FILTER_SANITIZE_SPECIAL_CHARS)
288            : $setup['password'];
289
290        if (is_null($password)) {
291            throw new Exception('Installation Error: Please add a password for your account.');
292        }
293
294        $passwordRetyped = $setup === null || !array_key_exists('password_retyped', $setup)
295            ? Filter::filterInput(INPUT_POST, 'password_retyped', FILTER_SANITIZE_SPECIAL_CHARS)
296            : $setup['password_retyped'];
297
298        if (is_null($passwordRetyped)) {
299            throw new Exception('Installation Error: Please add a retyped password.');
300        }
301
302        if (strlen((string) $password) <= 7 || strlen((string) $passwordRetyped) <= 7) {
303            throw new Exception(
304                'Installation Error: Your password and retyped password are too short. Please set your password '
305                . 'and your retyped password with a minimum of 8 characters.',
306            );
307        }
308
309        if (!hash_equals((string) $password, (string) $passwordRetyped)) {
310            throw new Exception(
311                'Installation Error: Your password and retyped password are not equal. Please check your password '
312                . 'and your retyped password.',
313            );
314        }
315
316        return [(string) $loginName, (string) $password];
317    }
318}