Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.79% covered (success)
91.79%
302 / 329
51.85% covered (warning)
51.85%
14 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
Client
91.79% covered (success)
91.79%
302 / 329
51.85% covered (warning)
51.85%
14 / 27
95.58
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setClientUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setClientTemplateSet
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 setFileSystem
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 filesystem
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 createClient
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createClientFolder
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 createClientDatabase
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 createClientTablesWithSchema
84.62% covered (success)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
4.06
 createClientTablesWithDatabase
91.38% covered (success)
91.38%
53 / 58
0.00% covered (danger)
0.00%
0 / 1
10.06
 copyBaseDataToSchema
96.55% covered (success)
96.55%
56 / 58
0.00% covered (danger)
0.00%
0 / 1
6
 executeSchemaQuery
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getDatabaseCredentials
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
7.01
 collectSeedRows
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 insertSeedRows
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 insertRows
92.00% covered (success)
92.00%
23 / 25
0.00% covered (danger)
0.00%
0 / 1
6.02
 quoteIdentifier
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
7.33
 createClientTables
84.09% covered (success)
84.09%
37 / 44
0.00% covered (danger)
0.00%
0 / 1
2.02
 copyConstantsFile
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 copyTemplateFolder
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 moveClientFolder
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 deleteClientFolder
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 isValidClientUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isValidClientHostname
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 isMultiSiteWriteable
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 extractClientHostnameFromUrl
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
12
 buildClientFolderPath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The main phpMyFAQ instances class for instance clients.
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     2012-03-31
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Instance;
21
22use phpMyFAQ\Configuration;
23use phpMyFAQ\Core\Exception;
24use phpMyFAQ\Database;
25use phpMyFAQ\Enums\TenantIsolationMode;
26use phpMyFAQ\Filesystem\Filesystem;
27use phpMyFAQ\Instance;
28use phpMyFAQ\Instance\Database as InstanceDatabase;
29
30/**
31 * Class Client
32 *
33 * @package phpMyFAQ\Instance
34 */
35/* @mago-expect lint:too-many-methods - covers the full tenant-provisioning lifecycle; split planned with the multi-tenancy rework */
36class Client extends Instance
37{
38    private ?Filesystem $filesystem = null;
39
40    private readonly string $clientFolder;
41
42    private string $clientUrl;
43
44    private string $clientTemplateSet = 'default';
45
46    /**
47     * Constructor.
48     */
49    public function __construct(Configuration $configuration)
50    {
51        parent::__construct($configuration);
52
53        $this->clientFolder = (string) PMF_ROOT_DIR . '/multisite/';
54    }
55
56    /**
57     * Sets client URL.
58     */
59    public function setClientUrl(string $clientUrl): void
60    {
61        $this->clientUrl = $clientUrl;
62    }
63
64    public function setClientTemplateSet(string $templateSet): void
65    {
66        $templateSet = trim($templateSet);
67        if ($templateSet === '') {
68            $templateSet = 'default';
69        }
70
71        $this->clientTemplateSet = $templateSet;
72    }
73
74    /**
75     * Sets the Filesystem.
76     */
77    public function setFileSystem(Filesystem $fileSystem): void
78    {
79        $this->filesystem = $fileSystem;
80    }
81
82    /**
83     * Returns the Filesystem or fails loudly when setFileSystem() was not
84     * called before a filesystem operation.
85     */
86    private function filesystem(): Filesystem
87    {
88        return (
89            $this->filesystem ?? throw new \LogicException(
90                'Client::setFileSystem() must be called before filesystem operations.',
91            )
92        );
93    }
94
95    public function createClient(Instance $instance): void
96    {
97        $instance->addConfig('isMaster', 'false');
98    }
99
100    /**
101     * Adds a new folder named by the given hostname in /path/to/faq/multisite/.
102     *
103     * @param string $hostname Hostname of the client instance
104     */
105    public function createClientFolder(string $hostname): bool
106    {
107        if (!$this->isMultiSiteWriteable() || !$this->isValidClientHostname($hostname)) {
108            return false;
109        }
110
111        return $this->filesystem()->createDirectory($this->buildClientFolderPath($hostname));
112    }
113
114    /**
115     * Creates client database isolation based on the configured tenant isolation mode.
116     *
117     * Supports three isolation strategies:
118     * - prefix: Table prefix isolation in a shared database (default, delegates to createClientTables)
119     * - schema: Schema-per-tenant in a shared database
120     * - database: Separate database per tenant
121     *
122     * @param string $tenantIdentifier The prefix, schema name, or database name for the tenant
123     * @param TenantIsolationMode|null $mode Isolation mode defaults to reading from PMF_TENANT_ISOLATION_MODE env var
124     */
125    public function createClientDatabase(string $tenantIdentifier, ?TenantIsolationMode $mode = null): void
126    {
127        $envValue = getenv('PMF_TENANT_ISOLATION_MODE');
128        $mode ??=
129            TenantIsolationMode::tryFrom($envValue !== false && $envValue !== '' ? $envValue : 'prefix')
130            ?? TenantIsolationMode::PREFIX;
131
132        match ($mode) {
133            TenantIsolationMode::PREFIX => $this->createClientTables($tenantIdentifier),
134            TenantIsolationMode::SCHEMA => $this->createClientTablesWithSchema($tenantIdentifier),
135            TenantIsolationMode::DATABASE => $this->createClientTablesWithDatabase($tenantIdentifier),
136        };
137    }
138
139    /**
140     * Creates all tables in a dedicated schema or database for tenant isolation.
141     *
142     * @param string $schema Schema or database name for the tenant
143     * @throws Exception
144     */
145    private function createClientTablesWithSchema(string $schema): void
146    {
147        try {
148            if (!preg_match('/^[A-Za-z0-9_]+$/', $schema)) {
149                throw new Exception('Invalid tenant schema identifier.');
150            }
151
152            $instanceDatabase = InstanceDatabase::factory($this->configuration, Database::getType());
153            if (!$instanceDatabase->createTables('', $schema)) {
154                throw new Exception('Failed to create tenant tables in schema.');
155            }
156
157            $this->copyBaseDataToSchema($schema);
158        } catch (Exception $exception) {
159            $this->configuration->getLogger()->error('Failed to create tenant schema tables.', [
160                'message' => $exception->getMessage(),
161                'trace' => $exception->getTraceAsString(),
162                'schema' => $schema,
163            ]);
164            throw $exception;
165        }
166    }
167
168    /**
169     * Creates all tables in a dedicated database for tenant isolation.
170     *
171     * Supported drivers: PostgreSQL, SQL Server.
172     *
173     * @throws Exception
174     */
175    private function createClientTablesWithDatabase(string $databaseName): void
176    {
177        if (!preg_match('/^[A-Za-z0-9_]+$/', $databaseName)) {
178            throw new Exception('Invalid tenant database identifier.');
179        }
180
181        $dbType = strtolower(Database::getType());
182        if (!str_contains($dbType, 'pgsql') && !str_contains($dbType, 'sqlsrv')) {
183            throw new Exception(sprintf(
184                'Database-per-tenant isolation is not supported for driver "%s". Use PostgreSQL or SQL Server.',
185                Database::getType(),
186            ));
187        }
188
189        $credentials = $this->getDatabaseCredentials();
190        if ($credentials === null) {
191            throw new Exception(sprintf('Database credentials not found for tenant database "%s".', $databaseName));
192        }
193
194        $sourcePrefix = Database::getTablePrefix();
195        $targetPrefix = $sourcePrefix;
196        $seedRows = $this->collectSeedRows($sourcePrefix);
197        $sourceDatabase = $credentials['database'];
198
199        try {
200            if (!InstanceDatabase::createTenantDatabase($this->configuration, Database::getType(), $databaseName)) {
201                throw new Exception(sprintf('Failed to create tenant database "%s".', $databaseName));
202            }
203
204            if (!$this->configuration->getDb()->connect(
205                $credentials['server'],
206                $credentials['user'],
207                $credentials['password'],
208                $databaseName,
209                $credentials['port'],
210            )) {
211                throw new Exception(sprintf(
212                    'Failed to connect to tenant database "%s" on server "%s": %s',
213                    $databaseName,
214                    $credentials['server'],
215                    $this->configuration->getDb()->error(),
216                ));
217            }
218
219            $instanceDatabase = InstanceDatabase::factory($this->configuration, Database::getType());
220            if (!$instanceDatabase->createTables($targetPrefix)) {
221                throw new Exception(sprintf(
222                    'Failed to create tables in tenant database "%s" with prefix "%s".',
223                    $databaseName,
224                    $targetPrefix,
225                ));
226            }
227
228            $this->insertSeedRows($targetPrefix, $seedRows);
229        } catch (Exception $exception) {
230            $this->configuration->getLogger()->error('Failed to create tenant database tables.', [
231                'message' => $exception->getMessage(),
232                'trace' => $exception->getTraceAsString(),
233                'database' => $databaseName,
234            ]);
235            throw $exception;
236        } finally {
237            try {
238                $this->configuration->getDb()->connect(
239                    $credentials['server'],
240                    $credentials['user'],
241                    $credentials['password'],
242                    $sourceDatabase,
243                    $credentials['port'],
244                );
245            } catch (\Throwable $reconnectException) {
246                $this->configuration->getLogger()->error('Failed to reconnect to source database.', [
247                    'message' => $reconnectException->getMessage(),
248                    'trace' => $reconnectException->getTraceAsString(),
249                    'database' => $sourceDatabase,
250                ]);
251            }
252        }
253    }
254
255    /**
256     * Copies base configuration, rights, and user data into a tenant's schema/database.
257     *
258     * @throws Exception
259     */
260    private function copyBaseDataToSchema(string $schema): void
261    {
262        if (!preg_match('/^[A-Za-z0-9_]+$/', $schema)) {
263            throw new Exception('Invalid tenant schema identifier.');
264        }
265
266        $dbType = Database::getType();
267        $sourcePrefix = Database::getTablePrefix();
268        $escapedClientUrl = $this->configuration->getDb()->escape($this->clientUrl);
269
270        $targetPrefix = sprintf('`%s`.', $schema);
271
272        if (str_contains($dbType, 'pgsql') || str_contains($dbType, 'Pgsql')) {
273            $targetPrefix = sprintf('"%s".', $schema);
274            $this->executeSchemaQuery(
275                sprintf('SET search_path TO "%s"', $schema),
276                'SET search_path',
277                $targetPrefix,
278                $sourcePrefix,
279            );
280        }
281
282        if (str_contains($dbType, 'sqlsrv') || str_contains($dbType, 'Sqlsrv')) {
283            $targetPrefix = sprintf('[%s].', $schema);
284        }
285
286        $this->executeSchemaQuery(
287            sprintf('INSERT INTO %sfaqconfig SELECT * FROM %sfaqconfig', $targetPrefix, $sourcePrefix),
288            'INSERT faqconfig',
289            $targetPrefix,
290            $sourcePrefix,
291        );
292
293        $this->executeSchemaQuery(
294            sprintf(
295                "UPDATE %sfaqconfig SET config_value = '%s' WHERE config_name = 'main.referenceURL'",
296                $targetPrefix,
297                $escapedClientUrl,
298            ),
299            'UPDATE faqconfig',
300            $targetPrefix,
301            $sourcePrefix,
302        );
303
304        $this->executeSchemaQuery(
305            sprintf(
306                "UPDATE %sfaqconfig SET config_value = '%s' WHERE config_name = 'layout.templateSet'",
307                $targetPrefix,
308                $this->configuration->getDb()->escape($this->clientTemplateSet),
309            ),
310            'UPDATE faqconfig',
311            $targetPrefix,
312            $sourcePrefix,
313        );
314
315        $this->executeSchemaQuery(
316            sprintf('INSERT INTO %sfaqright SELECT * FROM %sfaqright', $targetPrefix, $sourcePrefix),
317            'INSERT faqright',
318            $targetPrefix,
319            $sourcePrefix,
320        );
321
322        $this->executeSchemaQuery(
323            sprintf(
324                'INSERT INTO %sfaquser_right SELECT * FROM %sfaquser_right WHERE user_id = 1',
325                $targetPrefix,
326                $sourcePrefix,
327            ),
328            'INSERT faquser_right',
329            $targetPrefix,
330            $sourcePrefix,
331        );
332    }
333
334    /**
335     * Executes a query during schema data copy and throws on failure.
336     *
337     * @throws Exception
338     */
339    private function executeSchemaQuery(
340        string $query,
341        string $operation,
342        string $targetPrefix,
343        string $sourcePrefix,
344    ): void {
345        $result = $this->configuration->getDb()->query($query);
346
347        if ($result === false) {
348            throw new Exception(sprintf(
349                'Failed to %s (target: %s, source: %s): %s',
350                $operation,
351                $targetPrefix,
352                $sourcePrefix,
353                $this->configuration->getDb()->error(),
354            ));
355        }
356    }
357
358    /**
359     * @return array{server: string, port: int|null, user: string, password: string, database: string}|null
360     */
361    private function getDatabaseCredentials(): ?array
362    {
363        $databaseFile = (string) PMF_CONFIG_DIR . '/database.php';
364        if (!file_exists($databaseFile)) {
365            return null;
366        }
367
368        /** @var array<string, mixed> $DB populated by the included configuration file */
369        $DB = [];
370        include $databaseFile;
371
372        if (
373            !array_key_exists('server', $DB)
374            || !array_key_exists('user', $DB)
375            || !array_key_exists('password', $DB)
376            || !array_key_exists('db', $DB)
377        ) {
378            return null;
379        }
380
381        return [
382            'server' => (string) $DB['server'],
383            'port' => ($DB['port'] ?? '') === '' ? null : (int) $DB['port'],
384            'user' => (string) $DB['user'],
385            'password' => (string) $DB['password'],
386            'database' => (string) $DB['db'],
387        ];
388    }
389
390    /**
391     * Reads seed data from source database before switching to the tenant database.
392     *
393     * @return array<string, array<array-key, mixed>>
394     */
395    private function collectSeedRows(string $prefix): array
396    {
397        $tables = [
398            'faqconfig' => sprintf('SELECT * FROM %sfaqconfig', $prefix),
399            'faqright' => sprintf('SELECT * FROM %sfaqright', $prefix),
400            'faquser_right' => sprintf('SELECT * FROM %sfaquser_right WHERE user_id = 1', $prefix),
401        ];
402
403        $rows = [];
404        foreach ($tables as $table => $query) {
405            $result = $this->configuration->getDb()->query($query);
406            $rows[$table] = $result === false ? [] : $this->configuration->getDb()->fetchAll($result) ?? [];
407        }
408
409        return $rows;
410    }
411
412    /**
413     * @param array<string, array<array-key, mixed>> $seedRows
414     */
415    private function insertSeedRows(string $prefix, array $seedRows): void
416    {
417        $this->insertRows($prefix . 'faqconfig', $seedRows['faqconfig'] ?? []);
418        $this->configuration
419            ->getDb()
420            ->query(sprintf(
421                "UPDATE %sfaqconfig SET config_value = '%s' WHERE config_name = 'main.referenceURL'",
422                $prefix,
423                $this->configuration->getDb()->escape($this->clientUrl),
424            ));
425        $this->configuration
426            ->getDb()
427            ->query(sprintf(
428                "UPDATE %sfaqconfig SET config_value = '%s' WHERE config_name = 'layout.templateSet'",
429                $prefix,
430                $this->configuration->getDb()->escape($this->clientTemplateSet),
431            ));
432
433        $this->insertRows($prefix . 'faqright', $seedRows['faqright'] ?? []);
434        $this->insertRows($prefix . 'faquser_right', $seedRows['faquser_right'] ?? []);
435    }
436
437    private function insertRows(string $table, array $rows): void
438    {
439        foreach ($rows as $row) {
440            if (!is_array($row) && !is_object($row)) {
441                continue;
442            }
443
444            $rowData = (array) $row;
445            $quotedColumns = array_map(fn(int|string $column): string => $this->quoteIdentifier(
446                (string) $column,
447            ), array_keys($rowData));
448            $values = array_map(fn(mixed $value): string => $value === null
449                ? 'NULL'
450                : sprintf("'%s'", $this->configuration->getDb()->escape((string) $value)), array_values($rowData));
451
452            $query = sprintf(
453                'INSERT INTO %s (%s) VALUES (%s)',
454                $table,
455                implode(', ', $quotedColumns),
456                implode(', ', $values),
457            );
458
459            $result = $this->configuration->getDb()->query($query);
460
461            if ($result === false) {
462                $dbError = $this->configuration->getDb()->error();
463                $this->configuration->getLogger()->error('Failed to insert row into tenant table.', [
464                    'table' => $table,
465                    'query' => $query,
466                    'error' => $dbError,
467                ]);
468
469                throw new \RuntimeException(sprintf('Failed to insert row into %s: %s', $table, $dbError));
470            }
471        }
472    }
473
474    /**
475     * Quotes a column or table identifier for the current database driver.
476     */
477    private function quoteIdentifier(string $name): string
478    {
479        $dbType = Database::getType();
480
481        if (str_contains($dbType, 'sqlsrv') || str_contains($dbType, 'Sqlsrv')) {
482            return sprintf('[%s]', str_replace(search: ']', replace: ']]', subject: $name));
483        }
484
485        if (str_contains($dbType, 'pgsql') || str_contains($dbType, 'Pgsql') || str_contains($dbType, 'sqlite')) {
486            return sprintf('"%s"', str_replace(search: '"', replace: '""', subject: $name));
487        }
488
489        return sprintf('`%s`', str_replace(search: '`', replace: '``', subject: $name));
490    }
491
492    /**
493     * Creates all tables with the given table prefix from the primary tables.
494     *
495     * @param string $prefix SQL table prefix
496     * @throws Exception
497     */
498    public function createClientTables(string $prefix): void
499    {
500        try {
501            // First, create the client tables
502            $instanceDatabase = InstanceDatabase::factory($this->configuration, Database::getType());
503            $instanceDatabase->createTables($prefix);
504
505            // Then, copy data from the tables "faqconfig", "faqright" and "faquser_right"
506            $this->configuration
507                ->getDb()
508                ->query(sprintf(
509                    'INSERT INTO %sfaqconfig SELECT * FROM %sfaqconfig',
510                    $prefix,
511                    Database::getTablePrefix(),
512                ));
513            $this->configuration
514                ->getDb()
515                ->query(sprintf(
516                    "UPDATE %sfaqconfig SET config_value = '%s' WHERE config_name = 'main.referenceURL'",
517                    $prefix,
518                    $this->configuration->getDb()->escape($this->clientUrl),
519                ));
520            $this->configuration
521                ->getDb()
522                ->query(sprintf(
523                    "UPDATE %sfaqconfig SET config_value = '%s' WHERE config_name = 'layout.templateSet'",
524                    $prefix,
525                    $this->configuration->getDb()->escape($this->clientTemplateSet),
526                ));
527            $this->configuration
528                ->getDb()
529                ->query(sprintf(
530                    'INSERT INTO %sfaqright SELECT * FROM %sfaqright',
531                    $prefix,
532                    Database::getTablePrefix(),
533                ));
534            $this->configuration
535                ->getDb()
536                ->query(sprintf(
537                    'INSERT INTO %sfaquser_right SELECT * FROM %sfaquser_right WHERE user_id = 1',
538                    $prefix,
539                    Database::getTablePrefix(),
540                ));
541        } catch (Exception $exception) {
542            $this->configuration->getLogger()->error('Failed to create tenant prefix tables.', [
543                'message' => $exception->getMessage(),
544                'trace' => $exception->getTraceAsString(),
545                'prefix' => $prefix,
546            ]);
547            throw $exception;
548        }
549    }
550
551    /**
552     * Copies the config/constants.php file to a new client instance.
553     *
554     * @param string $destination Destination file
555     * @throws Exception
556     */
557    public function copyConstantsFile(string $destination): bool
558    {
559        return $this->filesystem()->copy(
560            $this->filesystem()->getRootPath() . '/content/core/config/constants.php',
561            $destination,
562        );
563    }
564
565    /**
566     * Copies a defined template folder to a new client instance; by default,
567     * the default template located at ./assets/templates/default/ will be copied.
568     *
569     * @param string $destination Destination folder
570     * @param string $templateDir Template folder
571     * @param bool $copyTemplateFiles Set to false to only reference the template set in config
572     * @throws Exception
573     */
574    public function copyTemplateFolder(
575        string $destination,
576        string $templateDir = 'default',
577        bool $copyTemplateFiles = true,
578    ): void {
579        $this->setClientTemplateSet($templateDir);
580
581        if (!$copyTemplateFiles) {
582            return;
583        }
584
585        $sourceTpl = $this->filesystem()->getRootPath() . '/assets/templates/' . $templateDir;
586        $destTpl = $destination . '/assets/templates/';
587
588        $this->filesystem()->recursiveCopy($sourceTpl, $destTpl);
589    }
590
591    /**
592     * Moves the entire folder to the new destination
593     */
594    public function moveClientFolder(string $sourceUrl, string $destinationUrl): bool
595    {
596        if (!$this->isMultiSiteWriteable()) {
597            return false;
598        }
599
600        $sourceHost = $this->extractClientHostnameFromUrl($sourceUrl);
601        $destinationHost = $this->extractClientHostnameFromUrl($destinationUrl);
602        if ($sourceHost === null || $destinationHost === null) {
603            return false;
604        }
605
606        return $this->filesystem()->moveDirectory(
607            $this->buildClientFolderPath($sourceHost),
608            $this->buildClientFolderPath($destinationHost),
609        );
610    }
611
612    /**
613     * Deletes the given client folder
614     */
615    public function deleteClientFolder(string $sourceUrl): bool
616    {
617        if (!$this->isMultiSiteWriteable()) {
618            return false;
619        }
620
621        $sourceHost = $this->extractClientHostnameFromUrl($sourceUrl);
622        if ($sourceHost === null) {
623            return false;
624        }
625
626        return $this->filesystem()->deleteDirectory($this->buildClientFolderPath($sourceHost));
627    }
628
629    public function isValidClientUrl(string $clientUrl): bool
630    {
631        return $this->extractClientHostnameFromUrl($clientUrl) !== null;
632    }
633
634    public function isValidClientHostname(string $hostname): bool
635    {
636        return (
637            $hostname !== ''
638            && preg_match('/^[a-z0-9][a-z0-9.-]*$/i', $hostname) === 1
639            && !str_contains($hostname, '..')
640        );
641    }
642
643    /**
644     * Checks if the multisite/ folder is writeable
645     */
646    public function isMultiSiteWriteable(): bool
647    {
648        return is_writable($this->clientFolder);
649    }
650
651    private function extractClientHostnameFromUrl(string $clientUrl): ?string
652    {
653        $parsedUrl = parse_url($clientUrl);
654        if (
655            !is_array($parsedUrl)
656            || ($parsedUrl['scheme'] ?? '') !== 'https'
657            || !array_key_exists('host', $parsedUrl)
658        ) {
659            return null;
660        }
661
662        if (($parsedUrl['path'] ?? '') !== '' && ($parsedUrl['path'] ?? '') !== '/') {
663            return null;
664        }
665
666        if (
667            array_key_exists('query', $parsedUrl)
668            || array_key_exists('fragment', $parsedUrl)
669            || array_key_exists('user', $parsedUrl)
670            || array_key_exists('pass', $parsedUrl)
671            || array_key_exists('port', $parsedUrl)
672        ) {
673            return null;
674        }
675
676        return $this->isValidClientHostname($parsedUrl['host']) ? $parsedUrl['host'] : null;
677    }
678
679    private function buildClientFolderPath(string $hostname): string
680    {
681        return rtrim($this->clientFolder, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $hostname;
682    }
683}