Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.94% covered (success)
93.94%
93 / 99
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Bootstrapper
93.94% covered (success)
93.94%
93 / 99
77.78% covered (warning)
77.78%
7 / 9
33.24
0.00% covered (danger)
0.00%
0 / 1
 config
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 run
87.50% covered (success)
87.50%
28 / 32
0.00% covered (danger)
0.00%
0 / 1
10.20
 getFaqConfig
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getDb
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRequest
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 connectDatabase
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
3
 switchToTenantSchema
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
9
 configureLdap
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
6.00
 fixProxyHeaders
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * Orchestrator for the phpMyFAQ bootstrap sequence
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2012-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-02-08
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ;
21
22use phpMyFAQ\Bootstrap\ConfigDirectoryResolver;
23use phpMyFAQ\Bootstrap\PhpConfigurator;
24use phpMyFAQ\Bootstrap\SearchClientFactory;
25use phpMyFAQ\Configuration\DatabaseConfiguration;
26use phpMyFAQ\Configuration\LdapConfiguration;
27use phpMyFAQ\Core\Exception;
28use phpMyFAQ\Core\Exception\DatabaseConnectionException;
29use phpMyFAQ\Database\DatabaseDriver;
30use RuntimeException;
31use Symfony\Component\HttpFoundation\Request;
32
33class Bootstrapper
34{
35    private ?Configuration $faqConfig = null;
36
37    private ?DatabaseDriver $db = null;
38
39    private ?Request $request = null;
40
41    /**
42     * Returns the configuration or fails loudly when boot() has not connected
43     * the database yet.
44     */
45    private function config(): Configuration
46    {
47        return (
48            $this->faqConfig ?? throw new \LogicException(
49                'Bootstrapper: the database must be connected before the configuration is available.',
50            )
51        );
52    }
53
54    /**
55     * Executes the full bootstrap sequence.
56     *
57     * @throws DatabaseConnectionException
58     */
59    public function run(): self
60    {
61        // 1. PHP runtime configuration
62        PhpConfigurator::fixIncludePath();
63        PhpConfigurator::configurePcre();
64
65        // 2. Environment
66        Environment::init();
67
68        // 3. Multisite support
69        $multisiteFile = (string) PMF_ROOT_DIR . '/multisite/multisite.php';
70        if (file_exists($multisiteFile) && 'cli' !== PHP_SAPI) {
71            require $multisiteFile;
72        }
73
74        // 4. Config directories
75        ConfigDirectoryResolver::resolve();
76
77        // 5. Database file detection (may redirect to setup)
78        $databaseFile = ConfigDirectoryResolver::resolveDatabaseFile();
79
80        // 6. Config-specific constants
81        ConfigDirectoryResolver::loadConfigConstants();
82
83        // 7. Translation directory
84        if (!defined('PMF_TRANSLATION_DIR')) {
85            define('PMF_TRANSLATION_DIR', (string) PMF_ROOT_DIR . '/translations');
86        }
87
88        // 8. Error handlers
89        PhpConfigurator::registerErrorHandlers();
90
91        // 9. Request
92        $request = Request::createFromGlobals();
93        $this->request = $request;
94
95        // 10. Output buffering
96        ob_start();
97
98        // 11. Database connection (only if a database file exists)
99        if ($databaseFile !== null) {
100            $this->connectDatabase($databaseFile);
101
102            // 12. Session configuration
103            PhpConfigurator::configureSession($this->config());
104
105            // 13. LDAP
106            $this->configureLdap();
107
108            $configDir = (string) PMF_CONFIG_DIR;
109
110            // 14. Elasticsearch
111            if (
112                (bool) $this->config()->get('search.enableElasticsearch')
113                && file_exists($configDir . '/elasticsearch.php')
114            ) {
115                SearchClientFactory::configureElasticsearch($this->config(), $configDir);
116            }
117
118            // 15. OpenSearch
119            if ((bool) $this->config()->get('search.enableOpenSearch') && file_exists($configDir . '/opensearch.php')) {
120                SearchClientFactory::configureOpenSearch($this->config(), $configDir);
121            }
122
123            // 16. Attachments directory
124            if (strtolower((string) $this->config()->get('storage.type')) !== 's3') {
125                ConfigDirectoryResolver::resolveAttachmentsDir(
126                    (string) $this->config()->get('records.attachmentsPath'),
127                    dirname(__DIR__, levels: 2),
128                );
129            }
130
131            // 17. Proxy header fix
132            $this->fixProxyHeaders($request);
133        }
134
135        return $this;
136    }
137
138    public function getFaqConfig(): ?Configuration
139    {
140        return $this->faqConfig;
141    }
142
143    public function getDb(): ?DatabaseDriver
144    {
145        return $this->db;
146    }
147
148    public function getRequest(): ?Request
149    {
150        return $this->request;
151    }
152
153    /**
154     * @throws DatabaseConnectionException
155     */
156    private function connectDatabase(string $databaseFile): void
157    {
158        try {
159            $dbConfig = new DatabaseConfiguration($databaseFile);
160            Database::setTablePrefix($dbConfig->getPrefix());
161            $db = Database::factory($dbConfig->getType());
162            $this->db = $db;
163            $db->connect(
164                $dbConfig->getServer(),
165                $dbConfig->getUser(),
166                $dbConfig->getPassword(),
167                $dbConfig->getDatabase(),
168                $dbConfig->getPort(),
169            );
170
171            $this->switchToTenantSchema($db, $dbConfig);
172        } catch (Exception|RuntimeException $exception) {
173            throw new DatabaseConnectionException(
174                message: 'Database connection failed: ' . $exception->getMessage(),
175                code: 500,
176                previous: $exception,
177            );
178        }
179
180        $this->faqConfig = new Configuration($db);
181        try {
182            $this->faqConfig->getAll();
183        } catch (Exception $exception) {
184            throw new DatabaseConnectionException(
185                message: 'Database tables not found or inaccessible: ' . $exception->getMessage(),
186                code: 500,
187                previous: $exception,
188            );
189        }
190    }
191
192    /**
193     * Switches to the tenant's schema or database after connection, if configured.
194     *
195     * @throws RuntimeException
196     */
197    private function switchToTenantSchema(DatabaseDriver $db, DatabaseConfiguration $dbConfig): void
198    {
199        $schema = $dbConfig->getSchema();
200        if ($schema === null || $schema === '') {
201            return;
202        }
203
204        $schema = trim($schema);
205        if (!preg_match('/^[A-Za-z0-9_]+$/', $schema)) {
206            throw new RuntimeException('Invalid tenant schema identifier.');
207        }
208
209        $dbType = $dbConfig->getType();
210
211        try {
212            if (str_contains($dbType, 'mysql')) {
213                $quotedSchema = sprintf('`%s`', str_replace(search: '`', replace: '``', subject: $schema));
214                $result = $db->query(sprintf('USE %s', $quotedSchema));
215                if ($result === false) {
216                    throw new RuntimeException('Failed to switch to tenant schema for MySQL.');
217                }
218                return;
219            }
220
221            if (str_contains($dbType, 'pgsql')) {
222                $quotedSchema = sprintf('"%s"', str_replace(search: '"', replace: '""', subject: $schema));
223                $result = $db->query(sprintf('SET search_path TO %s', $quotedSchema));
224                if ($result === false) {
225                    throw new RuntimeException('Failed to switch to tenant schema for PostgreSQL.');
226                }
227            }
228        } catch (\Throwable $exception) {
229            throw new RuntimeException(
230                'Failed to switch to tenant schema: ' . $exception->getMessage(),
231                previous: $exception,
232            );
233        }
234
235        // SQL Server uses a schema prefix in queries; no global switch needed.
236    }
237
238    private function configureLdap(): void
239    {
240        $ldapFile = (string) PMF_CONFIG_DIR . '/ldap.php';
241        if ($this->config()->isLdapActive() && file_exists($ldapFile) && extension_loaded('ldap')) {
242            $ldapConfig = new LdapConfiguration($ldapFile);
243            $this->config()->setLdapConfig($ldapConfig);
244        }
245    }
246
247    private function fixProxyHeaders(Request $request): void
248    {
249        if ($request->server->has('HTTP_HOST')) {
250            return;
251        }
252
253        if ($request->server->has('HTTP_X_FORWARDED_SERVER')) {
254            $request->server->set('HTTP_HOST', $request->server->get('HTTP_X_FORWARDED_SERVER'));
255            return;
256        }
257
258        $request->server->set('HTTP_HOST', $request->server->get('HTTP_X_FORWARDED_HOST'));
259    }
260}