Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.02% covered (danger)
5.02%
12 / 239
11.11% covered (danger)
11.11%
2 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
InstallationRunner
5.02% covered (danger)
5.02%
12 / 239
11.11% covered (danger)
11.11%
2 / 18
4513.70
0.00% covered (danger)
0.00%
0 / 1
 configuration
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 db
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 run
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
 stepValidateConnectivity
0.00% covered (danger)
0.00%
0 / 67
0.00% covered (danger)
0.00%
0 / 1
600
 stepCreateConfigFiles
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
132
 stepEstablishDbConnection
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 stepCreateDatabaseTables
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 stepInsertStopwords
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 stepSeedConfiguration
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 stepCreateAdminUser
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
30
 stepGrantPermissions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 stepInsertFormInputs
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 stepCreateAnonymousUser
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 stepCreateInstance
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 stepInitializeSearchEngine
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 buildOpenSearchClientOptions
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 stepAdjustHtaccess
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * Orchestrates the phpMyFAQ installation process as discrete, testable steps.
5 *
6 * Mirrors the UpdateRunner pattern: accepts validated InstallationInput and
7 * runs discrete steps in order. Supports dry-run for database operations.
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 Composer\Autoload\ClassLoader;
26use Elastic\Elasticsearch\ClientBuilder;
27use OpenSearch\SymfonyClientFactory;
28use phpMyFAQ\Configuration;
29use phpMyFAQ\Configuration\DatabaseConfiguration;
30use phpMyFAQ\Configuration\ElasticsearchConfiguration;
31use phpMyFAQ\Configuration\OpenSearchConfiguration;
32use phpMyFAQ\Core\Exception;
33use phpMyFAQ\Database;
34use phpMyFAQ\Database\DatabaseDriver;
35use phpMyFAQ\Entity\InstanceEntity;
36use phpMyFAQ\Forms;
37use phpMyFAQ\Instance;
38use phpMyFAQ\Instance\Database as InstanceDatabase;
39use phpMyFAQ\Instance\Database\Stopwords;
40use phpMyFAQ\Instance\Main;
41use phpMyFAQ\Instance\Search\Elasticsearch;
42use phpMyFAQ\Instance\Search\OpenSearch;
43use phpMyFAQ\Instance\Setup;
44use phpMyFAQ\Ldap;
45use phpMyFAQ\Link;
46use phpMyFAQ\Setup\Installation\DefaultDataSeeder;
47use phpMyFAQ\System;
48use phpMyFAQ\User;
49
50class InstallationRunner
51{
52    private ?Configuration $configuration = null;
53
54    private ?DatabaseDriver $db = null;
55
56    /**
57     * Returns the configuration created by the database-connection step or
58     * fails loudly when the steps run out of order.
59     */
60    private function configuration(): Configuration
61    {
62        return (
63            $this->configuration ?? throw new \LogicException(
64                'The database connection step must run before the configuration is available.',
65            )
66        );
67    }
68
69    /**
70     * Returns the connected database driver or fails loudly when the steps
71     * run out of order.
72     */
73    private function db(): DatabaseDriver
74    {
75        return (
76            $this->db ?? throw new \LogicException(
77                'The database connection step must run before the database is available.',
78            )
79        );
80    }
81
82    public function __construct(
83        private readonly System $system,
84    ) {
85    }
86
87    /**
88     * Runs the full installation using validated input.
89     *
90     * @throws Exception|\Exception
91     */
92    public function run(InstallationInput $input): void
93    {
94        $this->stepValidateConnectivity($input);
95        $this->stepCreateConfigFiles($input);
96        $this->stepEstablishDbConnection($input);
97        $this->stepCreateDatabaseTables($input);
98        $this->stepInsertStopwords($input);
99        $this->stepSeedConfiguration($input);
100        $this->stepCreateAdminUser($input);
101        $this->stepGrantPermissions($input);
102        $this->stepInsertFormInputs();
103        $this->stepCreateAnonymousUser($input);
104        $this->stepCreateInstance();
105        $this->stepInitializeSearchEngine($input);
106        $this->stepAdjustHtaccess($input);
107    }
108
109    /**
110     * Step 1: Validate database, LDAP, ES, and OpenSearch connectivity.
111     *
112     * @throws Exception
113     */
114    private function stepValidateConnectivity(InstallationInput $input): void
115    {
116        Database::setTablePrefix((string) ($input->dbSetup['dbPrefix'] ?? ''));
117        $db = Database::factory((string) ($input->dbSetup['dbType'] ?? ''));
118
119        try {
120            $dbPort = $input->dbSetup['dbPort'] ?? null;
121            $connected = $db->connect(
122                (string) ($input->dbSetup['dbServer'] ?? ''),
123                (string) ($input->dbSetup['dbUser'] ?? ''),
124                (string) ($input->dbSetup['dbPassword'] ?? ''),
125                (string) ($input->dbSetup['dbDatabaseName'] ?? ''),
126                $dbPort === null || $dbPort === '' ? null : (int) $dbPort,
127            );
128        } catch (\Throwable $e) {
129            throw new Exception(sprintf('Database Connection Error: %s', $e->getMessage()), 0, $e);
130        }
131
132        if ($connected === false || $connected === null) {
133            throw new Exception(sprintf('Database Connection Error: %s', $db->error()));
134        }
135
136        $configuration = new Configuration($db);
137
138        // Validate LDAP connection if enabled
139        if ($input->ldapEnabled && $input->ldapSetup !== []) {
140            $seeder = new DefaultDataSeeder();
141            foreach ($seeder->getMainConfig() as $configKey => $configValue) {
142                if (!str_contains($configKey, 'ldap.')) {
143                    continue;
144                }
145
146                $configuration->set($configKey, $configValue);
147            }
148
149            $ldap = new Ldap($configuration);
150            $ldapConnection = $ldap->connect(
151                (string) ($input->ldapSetup['ldapServer'] ?? ''),
152                (int) ($input->ldapSetup['ldapPort'] ?? 389),
153                (string) ($input->ldapSetup['ldapBase'] ?? ''),
154                (string) ($input->ldapSetup['ldapUser'] ?? ''),
155                (string) ($input->ldapSetup['ldapPassword'] ?? ''),
156            );
157
158            if (!$ldapConnection) {
159                throw new Exception(sprintf('LDAP Installation Error: %s.', $ldap->error()));
160            }
161        }
162
163        // Validate Elasticsearch connection if enabled
164        if ($input->esEnabled && $input->esSetup !== []) {
165            $classLoader = new ClassLoader();
166            $classLoader->addPsr4('Elasticsearch\\', PMF_SRC_DIR . '/libs/elasticsearch/src/Elasticsearch');
167            $classLoader->addPsr4('Monolog\\', PMF_SRC_DIR . '/libs/monolog/src/Monolog');
168            $classLoader->addPsr4('Psr\\', PMF_SRC_DIR . '/libs/psr/log/Psr');
169            $classLoader->addPsr4('React\\Promise\\', PMF_SRC_DIR . '/libs/react/promise/src');
170            $classLoader->register();
171
172            try {
173                $esHostsRaw = $input->esSetup['hosts'] ?? [];
174                $esHosts = array_values(is_array($esHostsRaw) ? $esHostsRaw : [(string) $esHostsRaw]);
175                $esClient = ClientBuilder::create()->setHosts($esHosts)->build();
176                $pingResponse = $esClient->ping();
177                if (
178                    !$pingResponse instanceof \Elastic\Elasticsearch\Response\Elasticsearch || !$pingResponse->asBool()
179                ) {
180                    throw new Exception('Elasticsearch Installation Error: Server did not respond to ping.');
181                }
182            } catch (Exception $e) {
183                throw $e;
184            } catch (\Throwable $e) {
185                throw new Exception(sprintf(
186                    'Elasticsearch Installation Error: Could not connect to Elasticsearch: %s',
187                    $e->getMessage(),
188                ));
189            }
190        }
191
192        // Validate OpenSearch connection if enabled
193        if ($input->osEnabled && $input->osSetup !== []) {
194            try {
195                $osHostsRaw = $input->osSetup['hosts'] ?? [];
196                $osHosts = array_values(is_array($osHostsRaw) ? $osHostsRaw : [(string) $osHostsRaw]);
197                $osClient = new SymfonyClientFactory()->create($this->buildOpenSearchClientOptions(
198                    (string) ($osHosts[0] ?? ''),
199                    $input->osSetup,
200                ));
201
202                if (!$osClient->ping()) {
203                    throw new Exception('OpenSearch Installation Error: Server did not respond to ping.');
204                }
205            } catch (Exception $e) {
206                throw $e;
207            } catch (\Throwable $e) {
208                throw new Exception(sprintf(
209                    'OpenSearch Installation Error: Could not connect to OpenSearch: %s',
210                    $e->getMessage(),
211                ));
212            }
213        }
214    }
215
216    /**
217     * Step 2: Write config files (database.php, ldap.php, elasticsearch.php, opensearch.php).
218     *
219     * @throws Exception
220     */
221    private function stepCreateConfigFiles(InstallationInput $input): void
222    {
223        $instanceSetup = new Setup();
224        $instanceSetup->setRootDir($input->rootDir);
225
226        if (!$instanceSetup->createDatabaseFile($input->dbSetup)) {
227            Installer::cleanFailedInstallationFiles();
228            throw new Exception('Installation Error: Setup cannot write to ./content/core/config/database.php.');
229        }
230
231        if ($input->ldapEnabled && $input->ldapSetup !== [] && !$instanceSetup->createLdapFile($input->ldapSetup, '')) {
232            Installer::cleanFailedInstallationFiles();
233            throw new Exception('LDAP Installation Error: Setup cannot write to ./content/core/config/ldap.php.');
234        }
235
236        if (
237            $input->esEnabled
238            && $input->esSetup !== []
239            && !$instanceSetup->createElasticsearchFile($input->esSetup, '')
240        ) {
241            Installer::cleanFailedInstallationFiles();
242            throw new Exception(
243                'Elasticsearch Installation Error: Setup cannot write to ./content/core/config/elasticsearch.php.',
244            );
245        }
246
247        if ($input->osEnabled && $input->osSetup !== [] && !$instanceSetup->createOpenSearchFile($input->osSetup, '')) {
248            Installer::cleanFailedInstallationFiles();
249            throw new Exception(
250                'OpenSearch Installation Error: Setup cannot write to ./content/core/config/opensearch.php.',
251            );
252        }
253    }
254
255    /**
256     * Step 3: Connect to the database using the freshly-written config file.
257     *
258     * @throws Exception
259     */
260    private function stepEstablishDbConnection(InstallationInput $input): void
261    {
262        $databaseConfiguration = new DatabaseConfiguration($input->rootDir . '/content/core/config/database.php');
263        try {
264            $this->db = Database::factory((string) ($input->dbSetup['dbType'] ?? ''));
265        } catch (Exception $exception) {
266            Installer::cleanFailedInstallationFiles();
267            throw new Exception(sprintf('Database Installation Error: %s', $exception->getMessage()));
268        }
269
270        try {
271            $connected = $this->db()->connect(
272                $databaseConfiguration->getServer(),
273                $databaseConfiguration->getUser(),
274                $databaseConfiguration->getPassword(),
275                $databaseConfiguration->getDatabase(),
276                $databaseConfiguration->getPort(),
277            );
278        } catch (\Throwable $e) {
279            Installer::cleanFailedInstallationFiles();
280            throw new Exception(sprintf('Database Installation Error: %s', $e->getMessage()), 0, $e);
281        }
282
283        if ($connected === false || $connected === null) {
284            Installer::cleanFailedInstallationFiles();
285            throw new Exception(sprintf('Database Installation Error: %s', $this->db()->error()));
286        }
287
288        $this->configuration = new Configuration($this->db());
289    }
290
291    /**
292     * Step 4: Create all database tables via SchemaInstaller.
293     *
294     * @throws Exception
295     */
296    private function stepCreateDatabaseTables(InstallationInput $input): void
297    {
298        try {
299            $databaseInstaller = InstanceDatabase::factory(
300                $this->configuration(),
301                (string) ($input->dbSetup['dbType'] ?? ''),
302            );
303            $result = $databaseInstaller->createTables((string) ($input->dbSetup['dbPrefix'] ?? ''));
304        } catch (Exception $exception) {
305            Installer::cleanFailedInstallationFiles();
306            throw new Exception(sprintf('Database Installation Error: %s', $exception->getMessage()));
307        }
308
309        if (!$result) {
310            Installer::cleanFailedInstallationFiles();
311            throw new Exception(sprintf(
312                'Database Installation Error: Failed to create tables for database type "%s" with prefix "%s".',
313                (string) ($input->dbSetup['dbType'] ?? ''),
314                (string) ($input->dbSetup['dbPrefix'] ?? ''),
315            ));
316        }
317    }
318
319    /**
320     * Step 5: Insert stopwords into the database.
321     */
322    private function stepInsertStopwords(InstallationInput $input): void
323    {
324        $stopWords = new Stopwords($this->configuration());
325        $stopWords->executeInsertQueries((string) ($input->dbSetup['dbPrefix'] ?? ''));
326
327        $this->system->setDatabase($this->db());
328    }
329
330    /**
331     * Step 6: Seed default configuration.
332     */
333    private function stepSeedConfiguration(InstallationInput $input): void
334    {
335        $seeder = new DefaultDataSeeder();
336        $seeder->applyPersonalSettings($input->realname, $input->getEmail(), $input->language, $input->permLevel);
337        $seeder->seedConfig($this->configuration());
338
339        $link = new Link('', $this->configuration());
340        $this->configuration()->update(['main.referenceURL' => $link->getSystemUri('/setup/index.php')]);
341        try {
342            $salt = bin2hex(random_bytes(32));
343        } catch (\Random\RandomException $e) {
344            throw new Exception(sprintf('Installation Error: Could not generate security salt: %s', $e->getMessage()));
345        }
346
347        $this->configuration()->add('security.salt', $salt);
348    }
349
350    /**
351     * Step 7: Create admin user (user_id = 1).
352     *
353     * @throws Exception
354     */
355    private function stepCreateAdminUser(InstallationInput $input): void
356    {
357        $user = new User($this->configuration());
358        if (!$user->createUser($input->getLoginName(), $input->getPassword(), '', 1)) {
359            Installer::cleanFailedInstallationFiles();
360            throw new Exception(sprintf(
361                'Fatal Installation Error: Could not create the admin user: %s',
362                $user->error(),
363            ));
364        }
365
366        if (!$user->setStatus('protected')) {
367            Installer::cleanFailedInstallationFiles();
368            throw new Exception(sprintf(
369                'Fatal Installation Error: Could not set admin user status: %s',
370                $user->error(),
371            ));
372        }
373
374        $adminData = [
375            'display_name' => $input->realname,
376            'email' => $input->getEmail(),
377        ];
378        if (!$user->setUserData($adminData)) {
379            Installer::cleanFailedInstallationFiles();
380            throw new Exception(sprintf('Fatal Installation Error: Could not set admin user data: %s', $user->error()));
381        }
382
383        if (!$user->setSuperAdmin(true)) {
384            Installer::cleanFailedInstallationFiles();
385            throw new Exception(sprintf(
386                'Fatal Installation Error: Could not set admin as super admin: %s',
387                $user->error(),
388            ));
389        }
390    }
391
392    /**
393     * Step 8: Grant all permissions to admin user.
394     */
395    private function stepGrantPermissions(InstallationInput $input): void
396    {
397        $user = new User($this->configuration());
398        $user->getUserById(1, true);
399
400        $seeder = new DefaultDataSeeder();
401        foreach ($seeder->getMainRights() as $mainRight) {
402            $user->perm->grantUserRight(1, $user->perm->addRight($mainRight));
403        }
404    }
405
406    /**
407     * Step 9: Insert form inputs.
408     */
409    private function stepInsertFormInputs(): void
410    {
411        $forms = new Forms($this->configuration());
412        $seeder = new DefaultDataSeeder();
413        foreach ($seeder->getFormInputs() as $formInput) {
414            $forms->insertInputIntoDatabase($formInput);
415        }
416    }
417
418    /**
419     * Step 10: Create anonymous user (user_id = -1).
420     *
421     * @throws Exception
422     */
423    private function stepCreateAnonymousUser(InstallationInput $input): void
424    {
425        $instanceSetup = new Setup();
426        $instanceSetup->setRootDir($input->rootDir);
427        $instanceSetup->createAnonymousUser($this->configuration());
428    }
429
430    /**
431     * Step 11: Create primary instance.
432     */
433    private function stepCreateInstance(): void
434    {
435        $link = new Link('', $this->configuration());
436        $instanceEntity = new InstanceEntity();
437        $instanceEntity
438            ->setUrl($link->getSystemUri())
439            ->setInstance($link->getSystemRelativeUri('setup/index.php'))
440            ->setComment('phpMyFAQ ' . System::getVersion());
441
442        $faqInstance = new Instance($this->configuration());
443        $faqInstance->create($instanceEntity);
444
445        $main = new Main($this->configuration());
446        $main->createMain($faqInstance);
447    }
448
449    /**
450     * Step 12: Initialize Elasticsearch/OpenSearch indices.
451     */
452    private function stepInitializeSearchEngine(InstallationInput $input): void
453    {
454        if ($input->esEnabled && is_file($input->rootDir . '/content/core/config/elasticsearch.php')) {
455            $elasticsearchConfiguration = new ElasticsearchConfiguration($input->rootDir
456            . '/content/core/config/elasticsearch.php');
457            $this->configuration()->setElasticsearchConfig($elasticsearchConfiguration);
458
459            $esClient = ClientBuilder::create()->setHosts($elasticsearchConfiguration->getHosts())->build();
460            $this->configuration()->setElasticsearch($esClient);
461
462            $elasticsearch = new Elasticsearch($this->configuration());
463            $elasticsearch->createIndex();
464        }
465
466        if ($input->osEnabled && is_file($input->rootDir . '/content/core/config/opensearch.php')) {
467            $openSearchConfiguration = new OpenSearchConfiguration($input->rootDir
468            . '/content/core/config/opensearch.php');
469            $this->configuration()->setOpenSearchConfig($openSearchConfiguration);
470
471            $osClient = new SymfonyClientFactory()->create($this->buildOpenSearchClientOptions(
472                $openSearchConfiguration->getHosts()[0],
473                $input->osSetup,
474            ));
475            $this->configuration()->setOpenSearch($osClient);
476
477            $openSearch = new OpenSearch($this->configuration());
478            $openSearch->createIndex();
479        }
480    }
481
482    /**
483     * Builds the options array for OpenSearch SymfonyClientFactory.
484     *
485     * Defaults to verify_peer=true; callers may pass optional TLS overrides
486     * (verify_peer, cafile, capath) via the $tlsSettings array.
487     *
488     * @param string $baseUri The OpenSearch server base URI
489     * @param array<string, mixed> $tlsSettings Optional TLS settings from osSetup
490     * @return array<string, mixed>
491     */
492    private function buildOpenSearchClientOptions(string $baseUri, array $tlsSettings = []): array
493    {
494        $options = [
495            'base_uri' => $baseUri,
496            'verify_peer' => true,
497        ];
498
499        if (array_key_exists('verify_peer', $tlsSettings)) {
500            $options['verify_peer'] = filter_var($tlsSettings['verify_peer'], FILTER_VALIDATE_BOOLEAN);
501        }
502
503        if (($tlsSettings['cafile'] ?? '') !== '') {
504            $options['cafile'] = (string) $tlsSettings['cafile'];
505        }
506
507        if (($tlsSettings['capath'] ?? '') !== '') {
508            $options['capath'] = (string) $tlsSettings['capath'];
509        }
510
511        return $options;
512    }
513
514    /**
515     * Step 13: Adjust .htaccess RewriteBase.
516     *
517     * Skips when the installation rootDir differs from the application's root path
518     * (e.g. in test environments) to avoid modifying the real .htaccess file.
519     */
520    private function stepAdjustHtaccess(InstallationInput $input): void
521    {
522        if (realpath($input->rootDir) !== realpath($this->configuration()->getRootPath())) {
523            return;
524        }
525
526        $environmentConfigurator = new EnvironmentConfigurator($this->configuration());
527        $environmentConfigurator->adjustRewriteBaseHtaccess();
528    }
529}