Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
2 / 2
CRAP
100.00% covered (success)
100.00%
1 / 1
MultisiteConfigurationLocator
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
2 / 2
12
100.00% covered (success)
100.00%
1 / 1
 locateConfigurationDirectory
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
6
 extractTenantFromSubdomain
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3/**
4 * Multisite configuration locator class
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 2025-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     2025-06-19
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Configuration;
21
22use Symfony\Component\HttpFoundation\Request;
23
24class MultisiteConfigurationLocator
25{
26    public static function locateConfigurationDirectory(Request $request, string $configurationDirectory): ?string
27    {
28        $protocol = $request->isSecure() ? 'https' : 'http';
29        $host = $request->getHost();
30        $scriptName = $request->getScriptName();
31
32        $parsed = parse_url($protocol . '://' . $host . $scriptName);
33
34        $parsedHost = (string) ($parsed['host'] ?? '');
35        if ($parsedHost !== '') {
36            // 1. Try an exact hostname match (existing behavior)
37            $configDir = rtrim($configurationDirectory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $parsedHost;
38
39            if (is_dir($configDir)) {
40                return $configDir;
41            }
42
43            // 2. Try subdomain-based tenant matching
44            $tenantName = self::extractTenantFromSubdomain($parsedHost);
45            if ($tenantName !== null) {
46                $configDir = rtrim($configurationDirectory, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $tenantName;
47
48                if (is_dir($configDir)) {
49                    return $configDir;
50                }
51            }
52        }
53
54        return null;
55    }
56
57    /**
58     * Extracts the tenant identifier from a subdomain pattern.
59     *
60     * Checks the PMF_MULTISITE_BASE_DOMAIN environment variable. If set,
61     * extracts the subdomain part from hostnames matching {tenant}.{baseDomain}.
62     *
63     * Example: With PMF_MULTISITE_BASE_DOMAIN=faq.example.com,
64     * the host "acme.faq.example.com" returns "acme".
65     */
66    public static function extractTenantFromSubdomain(string $host): ?string
67    {
68        $host = strtolower($host);
69        $baseDomain = getenv('PMF_MULTISITE_BASE_DOMAIN');
70        if ($baseDomain === false || $baseDomain === '') {
71            return null;
72        }
73
74        $baseDomain = strtolower(ltrim($baseDomain, characters: '.'));
75        $suffix = '.' . $baseDomain;
76
77        if (!str_ends_with($host, $suffix)) {
78            return null;
79        }
80
81        $tenant = strtolower(substr($host, offset: 0, length: -strlen($suffix)));
82        if ($tenant === '' || str_contains($tenant, '.')) {
83            return null;
84        }
85
86        return $tenant;
87    }
88}