[FEATURE] Add "Pseudo" Site functionality 67/57767/22
authorBenni Mack <benni@typo3.org>
Wed, 1 Aug 2018 20:44:58 +0000 (22:44 +0200)
committerWouter Wolters <typo3@wouterwolters.nl>
Tue, 21 Aug 2018 18:33:24 +0000 (20:33 +0200)
All pages on rootlevel or with "is-siteroot" need
to have a site object as well, but populated
by sys_domain (optional) and all available sys_language
records.

However, this information needs to be compiled
within an entity called "PseudoSite".

This way, the page-based routing ("slug handling")
can use the functionality to detect the uniqueness
of a slug within a page tree.

The routing of sites is moved to a "SiteMatcher" API
class, returning all found information on a request
for a site, also encapsulating the Symfony/Routing
component in there.

This would be exactly the step where further information
about a URL will then be resolved.

Next steps are the usage of Sites and PseudoSites
in TYPO3 Backend, allowing to only show e.g.
languages that are configured, which can be done
via $site->getAvailableLanguages().

Resolves: #85900
Releases: master
Change-Id: Ia2d27e58f21b5c89dc67dffcbc82d6612b350988
Reviewed-on: https://review.typo3.org/57767
Tested-by: TYPO3com <no-reply@typo3.com>
Reviewed-by: Georg Ringer <georg.ringer@gmail.com>
Tested-by: Georg Ringer <georg.ringer@gmail.com>
Reviewed-by: Wouter Wolters <typo3@wouterwolters.nl>
Tested-by: Wouter Wolters <typo3@wouterwolters.nl>
12 files changed:
typo3/sysext/backend/Classes/Middleware/SiteResolver.php
typo3/sysext/backend/Classes/Utility/BackendUtility.php
typo3/sysext/core/Classes/Routing/SiteMatcher.php [new file with mode: 0644]
typo3/sysext/core/Classes/Site/Entity/PseudoSite.php [new file with mode: 0644]
typo3/sysext/core/Classes/Site/Entity/Site.php
typo3/sysext/core/Classes/Site/PseudoSiteFinder.php [new file with mode: 0644]
typo3/sysext/core/Classes/Site/SiteFinder.php
typo3/sysext/core/Documentation/Changelog/master/Feature-85900-PseudoSiteHandling.rst [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/Site/Entity/PseudoSiteTest.php [new file with mode: 0644]
typo3/sysext/frontend/Classes/Compatibility/LegacyDomainResolver.php
typo3/sysext/frontend/Classes/Middleware/SiteResolver.php
typo3/sysext/frontend/Tests/Unit/Middleware/SiteResolverTest.php

index f28021c..e1a4178 100644 (file)
@@ -19,7 +19,10 @@ use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Server\MiddlewareInterface;
 use Psr\Http\Server\RequestHandlerInterface;
+use TYPO3\CMS\Backend\Utility\BackendUtility;
 use TYPO3\CMS\Core\Exception\SiteNotFoundException;
+use TYPO3\CMS\Core\Site\Entity\SiteInterface;
+use TYPO3\CMS\Core\Site\PseudoSiteFinder;
 use TYPO3\CMS\Core\Site\SiteFinder;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 
@@ -43,18 +46,27 @@ class SiteResolver implements MiddlewareInterface
      */
     public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
     {
-        $finder = GeneralUtility::makeInstance(SiteFinder::class);
         $site = null;
         $pageId = (int)($request->getQueryParams()['id'] ?? $request->getParsedBody()['id'] ?? 0);
 
         // Check if we have a _GET/_POST parameter for "id", then a site information can be resolved based.
         if ($pageId > 0) {
             try {
+                $finder = GeneralUtility::makeInstance(SiteFinder::class);
                 $site = $finder->getSiteByPageId($pageId);
-                $request = $request->withAttribute('site', $site);
-                $GLOBALS['TYPO3_REQUEST'] = $request;
             } catch (SiteNotFoundException $e) {
+                // Check for pseudo sites, based on given ID
+                $finder = GeneralUtility::makeInstance(PseudoSiteFinder::class);
+                $rootLine = BackendUtility::BEgetRootLine($pageId);
+                $site = $finder->getSiteByPageId($pageId, $rootLine);
             }
+        } else {
+            $finder = GeneralUtility::makeInstance(PseudoSiteFinder::class);
+            $site = $finder->getSiteByPageId(0);
+        }
+        if ($site instanceof SiteInterface) {
+            $request = $request->withAttribute('site', $site);
+            $GLOBALS['TYPO3_REQUEST'] = $request;
         }
         return $handler->handle($request);
     }
index 33f8855..3433af0 100644 (file)
@@ -42,6 +42,8 @@ use TYPO3\CMS\Core\Resource\File;
 use TYPO3\CMS\Core\Resource\ProcessedFile;
 use TYPO3\CMS\Core\Resource\ResourceFactory;
 use TYPO3\CMS\Core\Routing\PageUriBuilder;
+use TYPO3\CMS\Core\Routing\SiteMatcher;
+use TYPO3\CMS\Core\Site\Entity\PseudoSite;
 use TYPO3\CMS\Core\Site\SiteFinder;
 use TYPO3\CMS\Core\Type\Bitmask\Permission;
 use TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser;
@@ -2817,7 +2819,6 @@ class BackendUtility
         }
         // Checks alternate domains
         if (!empty($rootLine)) {
-            $urlParts = parse_url($domain);
             $protocol = GeneralUtility::getIndpEnv('TYPO3_SSL') ? 'https' : 'http';
             $previewDomainConfig = self::getPagesTSconfig($pageId)['TCEMAIN.']['previewDomain'] ?? '';
             if (!empty($previewDomainConfig)) {
@@ -2829,7 +2830,7 @@ class BackendUtility
             } else {
                 $domainResolver = GeneralUtility::makeInstance(LegacyDomainResolver::class);
                 foreach ($rootLine as $row) {
-                    $domainRecord = $domainResolver->matchRootPageId($row['uid']);
+                    $domainRecord = $domainResolver->matchRootPageId((int)$row['uid']);
                     if (is_array($domainRecord)) {
                         $domainName = rtrim($domainRecord['domainName'], '/');
                         break;
@@ -2839,11 +2840,16 @@ class BackendUtility
             if ($domainName) {
                 $domain = $domainName;
             } else {
-                $domainResolver = GeneralUtility::makeInstance(LegacyDomainResolver::class);
-                $rootPageId = $domainResolver->matchRequest(new ServerRequest($domain));
-                if ($rootPageId > 0) {
-                    $domainRecord = $domainResolver->matchRootPageId($rootPageId);
-                    $domain = $domainRecord['domainName'];
+                // Fetch the "sys_domain" record: First, check for the given domain,
+                // and find the "root page" = PseudoSite to that domain, then fetch the first
+                // available sys_domain record.
+                $siteMatcher = GeneralUtility::makeInstance(SiteMatcher::class);
+                $result = $siteMatcher->matchRequest(new ServerRequest($domain));
+                if (isset($result['site']) && $result['site'] instanceof PseudoSite) {
+                    /** @var PseudoSite $site */
+                    $site = $result['site'];
+                    $domain = $site->getBase();
+                    $domain = ltrim($domain, '/');
                 }
             }
             if ($domain) {
diff --git a/typo3/sysext/core/Classes/Routing/SiteMatcher.php b/typo3/sysext/core/Classes/Routing/SiteMatcher.php
new file mode 100644 (file)
index 0000000..5535ab1
--- /dev/null
@@ -0,0 +1,227 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Core\Routing;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use Psr\Http\Message\ServerRequestInterface;
+use Symfony\Component\Routing\Exception\NoConfigurationException;
+use Symfony\Component\Routing\Exception\ResourceNotFoundException;
+use Symfony\Component\Routing\Matcher\UrlMatcher;
+use Symfony\Component\Routing\RequestContext;
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Routing\RouteCollection;
+use TYPO3\CMS\Core\Exception\SiteNotFoundException;
+use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
+use TYPO3\CMS\Core\Site\PseudoSiteFinder;
+use TYPO3\CMS\Core\Site\SiteFinder;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\MathUtility;
+use TYPO3\CMS\Frontend\Page\PageRepository;
+
+/**
+ * Returns a site or pseudo-site (with sys_domain records) based on a given request.
+ *
+ * The main usage is the ->matchRequest() functionality, which receives a request object and boots up
+ * Symfony Routing to find the proper route with its defaults / attributes.
+ */
+class SiteMatcher
+{
+    /**
+     * @var SiteFinder
+     */
+    protected $finder;
+
+    /**
+     * Injects necessary objects
+     * @param SiteFinder|null $finder
+     */
+    public function __construct(SiteFinder $finder = null)
+    {
+        $this->finder = $finder ?? GeneralUtility::makeInstance(SiteFinder::class);
+    }
+
+    /**
+     * First, it is checked, if a "id" GET/POST parameter is found.
+     * If it is, we check for a valid site mounted there.
+     *
+     * If it isn't the quest continues by validating the whole request URL and validating against
+     * all available site records (and their language prefixes).
+     *
+     * If none is found, the "legacy" handling is checked for - checking for all pseudo-sites with
+     * a sys_domain record, and match against them.
+     *
+     * @param ServerRequestInterface $request
+     * @return array
+     */
+    public function matchRequest(ServerRequestInterface $request): array
+    {
+        $site = null;
+        $language = null;
+
+        $pageId = $request->getQueryParams()['id'] ?? $request->getParsedBody()['id'] ?? 0;
+        $languageId = $request->getQueryParams()['L'] ?? $request->getParsedBody()['L'] ?? null;
+
+        if (!empty($pageId) && !MathUtility::canBeInterpretedAsInteger($pageId)) {
+            $pageId = (int)GeneralUtility::makeInstance(PageRepository::class)->getPageIdFromAlias($pageId);
+        }
+        // First, check if we have a _GET/_POST parameter for "id", then a site information can be resolved based.
+        if ($pageId > 0 && $languageId !== null) {
+            // Loop over the whole rootline without permissions to get the actual site information
+            try {
+                $site = $this->finder->getSiteByPageId((int)$pageId);
+                // If a "L" parameter is given, we take that one into account.
+                if ($languageId !== null) {
+                    $language = $site->getLanguageById((int)$languageId);
+                } else {
+                    $allLanguages = $site->getLanguages();
+                    $language = reset($allLanguages);
+                }
+            } catch (SiteNotFoundException $e) {
+                // No site found by ID
+            }
+        }
+
+        // No language found at this point means that the URL was not used with a valid "?id" parameter
+        // which resulted in a site / language combination that was found. Now, the matching is done
+        // on the incoming URL
+        if (!($language instanceof SiteLanguage)) {
+            $collection = $this->getRouteCollectionForAllSites();
+            $context = new RequestContext(
+                '',
+                $request->getMethod(),
+                $request->getUri()->getHost(),
+                $request->getUri()->getScheme(),
+                // Ports are only necessary for URL generation in Symfony which is not used by TYPO3
+                80,
+                443,
+                $request->getUri()->getPath()
+            );
+            $matcher = new UrlMatcher($collection, $context);
+            try {
+                return $matcher->match($request->getUri()->getPath());
+            } catch (NoConfigurationException | ResourceNotFoundException $e) {
+                // No site found
+            }
+        }
+
+        // Check against any sys_domain records
+        $collection = $this->getRouteCollectionForVisibleSysDomains();
+        $context = new RequestContext('/', $request->getMethod(), $request->getUri()->getHost());
+        $matcher = new UrlMatcher($collection, $context);
+        if ((bool)$GLOBALS['TYPO3_CONF_VARS']['SYS']['recursiveDomainSearch']) {
+            $host = explode('.', $request->getUri()->getHost());
+            while (count($host)) {
+                $context->setHost(implode('.', $host));
+                try {
+                    return $matcher->match($request->getUri()->getPath());
+                } catch (NoConfigurationException | ResourceNotFoundException $e) {
+                    array_shift($host);
+                }
+            }
+        } else {
+            try {
+                return $matcher->match($request->getUri()->getPath());
+            } catch (NoConfigurationException | ResourceNotFoundException $e) {
+                // No domain record found
+            }
+        }
+        return ['site' => $site, 'language' => $language];
+    }
+
+    /**
+     * Returns a Symfony RouteCollection containing all routes to all sites.
+     *
+     * {next} is not evaluated yet, but set as suffix and will change in the future.
+     *
+     * @return RouteCollection
+     */
+    protected function getRouteCollectionForAllSites(): RouteCollection
+    {
+        $groupedRoutes = [];
+        foreach ($this->finder->getAllSites() as $site) {
+            foreach ($site->getAllLanguages() as $siteLanguage) {
+                $urlParts = parse_url($siteLanguage->getBase());
+                $route = new Route(
+                    ($urlParts['path'] ?? '/') . '{next}',
+                    ['site' => $site, 'language' => $siteLanguage, 'next' => ''],
+                    array_filter(['next' => '.*', 'port' => (string)($urlParts['port'] ?? '')]),
+                    ['utf8' => true],
+                    $urlParts['host'] ?? '',
+                    !empty($urlParts['scheme']) ? [$urlParts['scheme']] : null
+                );
+                $identifier = 'site_' . $site->getIdentifier() . '_' . $siteLanguage->getLanguageId();
+                $groupedRoutes[($urlParts['scheme'] ?? '-') . ($urlParts['host'] ?? '-')][$urlParts['path'] ?? '/'][$identifier] = $route;
+            }
+        }
+        return $this->createRouteCollectionFromGroupedRoutes($groupedRoutes);
+    }
+
+    /**
+     * Return the page ID (pid) of a sys_domain record, based on a request object, does the infamous
+     * "recursive domain search", to also detect if the domain is like "abc.def.example.com" even if the
+     * sys_domain entry is "example.com".
+     *
+     * @return RouteCollection
+     */
+    protected function getRouteCollectionForVisibleSysDomains(): RouteCollection
+    {
+        $sites = GeneralUtility::makeInstance(PseudoSiteFinder::class)->findAll();
+        $groupedRoutes = [];
+        foreach ($sites as $site) {
+            foreach ($site->getEntryPoints() as $domainName) {
+                // Site has no sys_domain record, it is not valid for a routing entrypoint, but only available
+                // via "id" GET parameter which is handled before
+                if ($domainName === '/') {
+                    continue;
+                }
+                $urlParts = parse_url($domainName);
+                $route = new Route(
+                    ($urlParts['path'] ?? '/') . '{next}',
+                    ['site' => $site, 'language' => null, 'next' => ''],
+                    array_filter(['next' => '.*', 'port' => (string)($urlParts['port'] ?? '')]),
+                    ['utf8' => true],
+                    $urlParts['host'] ?? '',
+                    !empty($urlParts['scheme']) ? [$urlParts['scheme']] : null
+                );
+                $identifier = 'site_' . $site->getIdentifier() . '_' . $domainName;
+                $groupedRoutes[($urlParts['scheme'] ?? '-') . ($urlParts['host'] ?? '-')][$urlParts['path'] ?? '/'][$identifier] = $route;
+            }
+        }
+        return $this->createRouteCollectionFromGroupedRoutes($groupedRoutes);
+    }
+
+    /**
+     * As the {next} parameter is greedy, it needs to be ensured that the one with the
+     * most specific part matches first.
+     *
+     * @param array $groupedRoutes
+     * @return RouteCollection
+     */
+    protected function createRouteCollectionFromGroupedRoutes(array $groupedRoutes): RouteCollection
+    {
+        $collection = new RouteCollection();
+        foreach ($groupedRoutes as $groupedRoutesPerHost) {
+            krsort($groupedRoutesPerHost);
+            foreach ($groupedRoutesPerHost as $groupedRoutesPerPath) {
+                krsort($groupedRoutesPerPath);
+                foreach ($groupedRoutesPerPath as $identifier => $route) {
+                    $collection->add($identifier, $route);
+                }
+            }
+        }
+        return $collection;
+    }
+}
diff --git a/typo3/sysext/core/Classes/Site/Entity/PseudoSite.php b/typo3/sysext/core/Classes/Site/Entity/PseudoSite.php
new file mode 100644 (file)
index 0000000..4e06a52
--- /dev/null
@@ -0,0 +1,242 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Core\Site\Entity;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Backend\Utility\BackendUtility;
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Error\PageErrorHandler\PageErrorHandlerInterface;
+use TYPO3\CMS\Core\Localization\LanguageService;
+
+/**
+ * Entity representing a site with legacy configuration (sys_domain) and all available languages in the system (sys_language)
+ */
+class PseudoSite implements SiteInterface
+{
+    /**
+     * @var string[]
+     */
+    protected $entryPoints;
+
+    /**
+     * @var int
+     */
+    protected $rootPageId;
+
+    /**
+     * @var SiteLanguage[]
+     */
+    protected $languages;
+
+    /**
+     * attached sys_domain records
+     * @var array
+     */
+    protected $domainRecords = [];
+
+    /**
+     * Sets up a pseudo site object, and its languages and error handlers
+     *
+     * @param int $rootPageId
+     * @param array $configuration
+     */
+    public function __construct(int $rootPageId, array $configuration)
+    {
+        $this->rootPageId = $rootPageId;
+        foreach ($configuration['domains'] ?? [] as $domain) {
+            if (empty($domain['domainName'] ?? false)) {
+                continue;
+            }
+            $this->domainRecords[] = $domain;
+            $this->entryPoints[] = $this->sanitizeBaseUrl($domain['domainName'] ?: '');
+        }
+        if (empty($this->entryPoints)) {
+            $this->entryPoints = ['/'];
+        }
+        $baseEntryPoint = reset($this->entryPoints);
+        foreach ($configuration['languages'] as $languageConfiguration) {
+            $languageUid = (int)$languageConfiguration['languageId'];
+            // Language configuration does not have a base defined
+            // So the main site base is used (usually done for default languages)
+            $base = $this->sanitizeBaseUrl(rtrim($baseEntryPoint, '/') . '/');
+            $this->languages[$languageUid] = new SiteLanguage(
+                $languageUid,
+                $languageConfiguration['locale'] ?? '',
+                $base,
+                $languageConfiguration
+            );
+        }
+    }
+
+    /**
+     * Returns a generic identifier
+     *
+     * @return string
+     */
+    public function getIdentifier(): string
+    {
+        return 'PSEUDO_' . $this->rootPageId;
+    }
+
+    /**
+     * Returns the first base URL of this site, falls back to "/"
+     */
+    public function getBase(): string
+    {
+        return $this->entryPoints[0] ?? '/';
+    }
+
+    /**
+     * Returns the base URLs of this site, if none given, it's always "/"
+     *
+     * @return array
+     */
+    public function getEntryPoints(): array
+    {
+        return $this->entryPoints;
+    }
+
+    /**
+     * Returns the root page ID of this site
+     *
+     * @return int
+     */
+    public function getRootPageId(): int
+    {
+        return $this->rootPageId;
+    }
+
+    /**
+     * Returns all available languages of this site
+     *
+     * @return SiteLanguage[]
+     */
+    public function getLanguages(): array
+    {
+        return $this->languages;
+    }
+
+    /**
+     * Returns a language of this site, given by the sys_language_uid
+     *
+     * @param int $languageId
+     * @return SiteLanguage
+     * @throws \InvalidArgumentException
+     */
+    public function getLanguageById(int $languageId): SiteLanguage
+    {
+        if (isset($this->languages[$languageId])) {
+            return $this->languages[$languageId];
+        }
+        throw new \InvalidArgumentException(
+            'Language ' . $languageId . ' does not exist on site ' . $this->getIdentifier() . '.',
+            1522965188
+        );
+    }
+
+    /**
+     * Fetch the available languages for a specific backend user, used in various places in Backend and Frontend
+     * when a Backend User is authenticated.
+     *
+     * @param BackendUserAuthentication $user
+     * @param int $pageId
+     * @param bool $includeAllLanguagesFlag whether to include "-1" into the list, useful for some backend outputs
+     * @return array
+     */
+    public function getAvailableLanguages(BackendUserAuthentication $user, int $pageId, bool $includeAllLanguagesFlag = false)
+    {
+        $availableLanguages = [];
+
+        // Check if we need to add language "-1"
+        if ($includeAllLanguagesFlag && $user->checkLanguageAccess(-1)) {
+            $availableLanguages[-1] = new SiteLanguage(-1, '', $this->getBase(), [
+                'title' => $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:multipleLanguages'),
+                'flag' => 'flag-multiple'
+            ]);
+        }
+
+        // Do not add the ones that are not allowed by the user
+        foreach ($this->languages as $language) {
+            if ($user->checkLanguageAccess($language->getLanguageId())) {
+                if ($language->getLanguageId() === 0) {
+                    $pageTs = BackendUtility::getPagesTSconfig($pageId);
+                    // 0: "Default" language
+                    $defaultLanguageLabel = 'LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:defaultLanguage';
+                    if (isset($pageTs['mod.']['SHARED.']['defaultLanguageLabel'])) {
+                        $defaultLanguageLabel = $pageTs['mod.']['SHARED.']['defaultLanguageLabel'] . ' (' . $this->getLanguageService()->sL($defaultLanguageLabel) . ')';
+                    }
+                    $defaultLanguageFlag = 'empty-empty';
+                    if (isset($pageTs['mod.']['SHARED.']['defaultLanguageFlag'])) {
+                        $defaultLanguageFlag = 'flags-' . $pageTs['mod.']['SHARED.']['defaultLanguageFlag'];
+                    }
+                    $language = new SiteLanguage(0, '', $language->getBase(), [
+                        'title' => $this->getLanguageService()->sL($defaultLanguageLabel),
+                        'flag' => $defaultLanguageFlag,
+                    ]);
+                }
+                $availableLanguages[$language->getLanguageId()] = $language;
+            }
+        }
+
+        return $availableLanguages;
+    }
+
+    /**
+     * Returns a ready-to-use error handler, to be used within the ErrorController
+     *
+     * @param int $statusCode
+     * @return PageErrorHandlerInterface
+     * @throws \RuntimeException
+     */
+    public function getErrorHandler(int $statusCode): PageErrorHandlerInterface
+    {
+        throw new \RuntimeException('No error handler given for the status code "' . $statusCode . '".', 1522495102);
+    }
+
+    /**
+     * If a site base contains "/" or "www.domain.com", it is ensured that
+     * parse_url() can handle this kind of configuration properly.
+     *
+     * @param string $base
+     * @return string
+     */
+    protected function sanitizeBaseUrl(string $base): string
+    {
+        // no protocol ("//") and the first part is no "/" (path), means that this is a domain like
+        // "www.domain.com/blabla", and we want to ensure that this one then gets a "no-scheme agnostic" part
+        if (!empty($base) && strpos($base, '//') === false && $base{0} !== '/') {
+            // either a scheme is added, or no scheme but with domain, or a path which is not absolute
+            // make the base prefixed with a slash, so it is recognized as path, not as domain
+            // treat as path
+            if (strpos($base, '.') === false) {
+                $base = '/' . $base;
+            } else {
+                // treat as domain name
+                $base = '//' . $base;
+            }
+        }
+        return $base;
+    }
+
+    /**
+     * Shorthand functionality for fetching the language service
+     * @return LanguageService
+     */
+    protected function getLanguageService(): LanguageService
+    {
+        return $GLOBALS['LANG'];
+    }
+}
index afe1f61..2ab87df 100644 (file)
@@ -16,11 +16,13 @@ namespace TYPO3\CMS\Core\Site\Entity;
  * The TYPO3 project - inspiring people to share!
  */
 
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
 use TYPO3\CMS\Core\Error\PageErrorHandler\FluidPageErrorHandler;
 use TYPO3\CMS\Core\Error\PageErrorHandler\InvalidPageErrorHandlerException;
 use TYPO3\CMS\Core\Error\PageErrorHandler\PageContentErrorHandler;
 use TYPO3\CMS\Core\Error\PageErrorHandler\PageErrorHandlerInterface;
 use TYPO3\CMS\Core\Error\PageErrorHandler\PageErrorHandlerNotConfiguredException;
+use TYPO3\CMS\Core\Localization\LanguageService;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 
 /**
@@ -196,6 +198,37 @@ class Site implements SiteInterface
     }
 
     /**
+     * Fetch the available languages for a specific backend user, used in various places in Backend and Frontend
+     * when a Backend User is authenticated.
+     *
+     * @param BackendUserAuthentication $user
+     * @param int $pageId
+     * @param bool $includeAllLanguagesFlag
+     * @return array
+     */
+    public function getAvailableLanguages(BackendUserAuthentication $user, int $pageId, bool $includeAllLanguagesFlag = false)
+    {
+        $availableLanguages = [];
+
+        // Check if we need to add language "-1"
+        if ($includeAllLanguagesFlag && $user->checkLanguageAccess(-1)) {
+            $availableLanguages[-1] = new SiteLanguage(-1, '', $this->getBase(), [
+                'title' => $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:multipleLanguages'),
+                'flag' => 'flag-multiple'
+            ]);
+        }
+
+        // Do not add the ones that are not allowed by the user
+        foreach ($this->languages as $language) {
+            if ($user->checkLanguageAccess($language->getLanguageId())) {
+                $availableLanguages[$language->getLanguageId()] = $language;
+            }
+        }
+
+        return $availableLanguages;
+    }
+
+    /**
      * Returns a ready-to-use error handler, to be used within the ErrorController
      *
      * @param int $statusCode
@@ -274,4 +307,13 @@ class Site implements SiteInterface
         }
         return $base;
     }
+
+    /**
+     * Shorthand functionality for fetching the language service
+     * @return LanguageService
+     */
+    protected function getLanguageService(): LanguageService
+    {
+        return $GLOBALS['LANG'];
+    }
 }
diff --git a/typo3/sysext/core/Classes/Site/PseudoSiteFinder.php b/typo3/sysext/core/Classes/Site/PseudoSiteFinder.php
new file mode 100644 (file)
index 0000000..e8647f9
--- /dev/null
@@ -0,0 +1,220 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Core\Site;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Cache\CacheManager;
+use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
+use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
+use TYPO3\CMS\Core\Exception\Page\PageNotFoundException;
+use TYPO3\CMS\Core\Exception\SiteNotFoundException;
+use TYPO3\CMS\Core\SingletonInterface;
+use TYPO3\CMS\Core\Site\Entity\PseudoSite;
+use TYPO3\CMS\Core\Site\Entity\SiteInterface;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\RootlineUtility;
+use TYPO3\CMS\Frontend\Compatibility\LegacyDomainResolver;
+
+/**
+ * Methods related to "pseudo-sites" = sites that do not have a configuration yet.
+ * @internal
+ */
+class PseudoSiteFinder implements SingletonInterface
+{
+    /**
+     * @var FrontendInterface
+     */
+    protected $cache;
+
+    /**
+     * @var PseudoSite[]
+     */
+    protected $pseudoSites = [];
+
+    public function __construct()
+    {
+        $this->cache = GeneralUtility::makeInstance(CacheManager::class)->getCache('cache_core');
+        $this->populate();
+    }
+
+    /**
+     * Fetches all site root pages, all sys_language and sys_domain records and forms pseudo-sites,
+     * but only for the pagetree's that do not have a site configuration available.
+     */
+    protected function populate()
+    {
+        $data = $this->cache->get('pseudo-sites');
+        if (empty($data)) {
+            $allLanguages = $this->getAllLanguageRecords();
+            $groupedDomains = GeneralUtility::makeInstance(LegacyDomainResolver::class)->getGroupedDomainsPerPage();
+            $availablePages = $this->getAllRootPagesWithoutSiteConfiguration();
+            $this->cache->set('pseudo-sites', json_encode([$allLanguages, $groupedDomains, $availablePages]));
+        } else {
+            // Due to the nature of PhpFrontend, the `<?php` and `#` wraps have to be removed
+            $data = preg_replace('/^<\?php\s*|\s*#$/', '', $data);
+            list($allLanguages, $groupedDomains, $availablePages) = json_decode($data, true);
+        }
+
+        $this->pseudoSites = [];
+        foreach ($availablePages as $row) {
+            $rootPageId = (int)$row['uid'];
+            $site = new PseudoSite($rootPageId, [
+                'domains' => $groupedDomains[$rootPageId] ?? [],
+                'languages' => $allLanguages
+            ]);
+            unset($groupedDomains[$rootPageId]);
+            $this->pseudoSites[$rootPageId] = $site;
+        }
+
+        // Now add the records where there is a sys_domain record but not configured as root page
+        foreach ($groupedDomains as $rootPageId => $domainRecords) {
+            $site = new PseudoSite((int)$rootPageId, [
+                'domains' => $domainRecords,
+                'languages' => $allLanguages
+            ]);
+            $this->pseudoSites[$rootPageId] = $site;
+        }
+
+        // Now lets an empty Pseudo-Site for visiting things on pid=0
+        $this->pseudoSites[0] = new PseudoSite(0, ['languages' => $allLanguages]);
+    }
+
+    /**
+     * Returns all pseudo sites, including one for "pid=0".
+     *
+     * @return PseudoSite[]
+     */
+    public function findAll(): array
+    {
+        if (empty($this->pseudoSites)) {
+            $this->populate();
+        }
+        return $this->pseudoSites;
+    }
+
+    /**
+     * Fetches all "sys_language" records.
+     *
+     * @return array
+     */
+    protected function getAllLanguageRecords(): array
+    {
+        $languageRecords = [
+            0 => [
+                'uid' => 0,
+                'title' => 'Default',
+                'flag' => 'empty-empty',
+            ],
+        ];
+
+        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_language');
+        $queryBuilder->getRestrictions()->removeByType(HiddenRestriction::class);
+        $statement = $queryBuilder
+            ->select('*')
+            ->from('sys_language')
+            ->orderBy('sorting')
+            ->execute();
+        while ($row = $statement->fetch()) {
+            $uid = $row['uid'];
+            $languageRecords[$uid] = [
+                'uid' => $uid,
+                'title' => $row['title'],
+                'iso' => $row['language_isocode'] ?? '',
+                'flag' => 'flags-' . $row['flag'],
+            ];
+        }
+
+        return $languageRecords;
+    }
+
+    /**
+     * Traverses the rootline of a page up until a PseudoSite was found.
+     * The main use-case here is in the TYPO3 Backend when the middleware tries to detect
+     * a PseudoSite
+     *
+     * @param int $pageId
+     * @param array $rootLine
+     * @return SiteInterface
+     * @throws SiteNotFoundException
+     */
+    public function getSiteByPageId(int $pageId, array $rootLine = null): SiteInterface
+    {
+        $this->findAll();
+        if ($pageId === 0) {
+            return $this->pseudoSites[0];
+        }
+        if (!is_array($rootLine)) {
+            try {
+                $rootLine = GeneralUtility::makeInstance(RootlineUtility::class, $pageId)->get();
+            } catch (PageNotFoundException $e) {
+                $rootLine = [];
+            }
+        }
+        foreach ($rootLine as $pageInRootLine) {
+            if (isset($this->pseudoSites[(int)$pageInRootLine['uid']])) {
+                return $this->pseudoSites[(int)$pageInRootLine['uid']];
+            }
+        }
+        throw new SiteNotFoundException('No pseudo-site found in root line of page  ' . $pageId, 1534710048);
+    }
+
+    /**
+     * Loads all sites with a configuration, and takes their rootPageId.
+     *
+     * @return array
+     */
+    protected function getExistingSiteConfigurationRootPageIds(): array
+    {
+        $usedPageIds = [];
+        $finder = GeneralUtility::makeInstance(SiteFinder::class);
+        $sites = $finder->getAllSites();
+        foreach ($sites as $site) {
+            $usedPageIds[] = $site->getRootPageId();
+        }
+        return $usedPageIds;
+    }
+
+    /**
+     * Do a SQL query for root pages (pid=0 or is_siteroot=1) that do not have a site configuration
+     * @return array
+     */
+    protected function getAllRootPagesWithoutSiteConfiguration(): array
+    {
+        $usedPageIds = $this->getExistingSiteConfigurationRootPageIds();
+        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
+        $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
+        $queryBuilder
+            ->select('uid')
+            ->from('pages')
+            ->where(
+                $queryBuilder->expr()->eq('sys_language_uid', 0),
+                $queryBuilder->expr()->orX(
+                    $queryBuilder->expr()->eq('pid', 0),
+                    $queryBuilder->expr()->eq('is_siteroot', 1)
+                )
+            )
+            ->orderBy('pid')
+            ->addOrderBy('sorting');
+
+        if (!empty($usedPageIds)) {
+            $queryBuilder->andWhere($queryBuilder->expr()->notIn('uid', $usedPageIds));
+        }
+        $availablePages = $queryBuilder->execute()->fetchAll();
+        return is_array($availablePages) ? $availablePages : [];
+    }
+}
index 5f1ab5e..47a9060 100644 (file)
@@ -16,8 +16,6 @@ namespace TYPO3\CMS\Core\Site;
  * The TYPO3 project - inspiring people to share!
  */
 
-use Symfony\Component\Routing\Route;
-use Symfony\Component\Routing\RouteCollection;
 use TYPO3\CMS\Core\Configuration\SiteConfiguration;
 use TYPO3\CMS\Core\Core\Environment;
 use TYPO3\CMS\Core\Exception\Page\PageNotFoundException;
@@ -68,47 +66,6 @@ class SiteFinder
     }
 
     /**
-     * Returns a Symfony RouteCollection containing all routes to all sites.
-     *
-     * {next} is not evaluated yet, but set as suffix and will change in the future.
-     *
-     * @return RouteCollection
-     * @internal this method will likely change due to further extraction into custom logic for Routing
-     */
-    public function getRouteCollectionForAllSites(): RouteCollection
-    {
-        $collection = new RouteCollection();
-        $groupedRoutes = [];
-        foreach ($this->sites as $site) {
-            foreach ($site->getAllLanguages() as $siteLanguage) {
-                $urlParts = parse_url($siteLanguage->getBase());
-                $route = new Route(
-                    ($urlParts['path'] ?? '/') . '{next}',
-                    ['next' => '', 'site' => $site, 'language' => $siteLanguage],
-                    array_filter(['next' => '.*', 'port' => (string)($urlParts['port'] ?? '')]),
-                    ['utf8' => true],
-                    $urlParts['host'] ?? '',
-                    !empty($urlParts['scheme']) ? [$urlParts['scheme']] : null
-                );
-                $identifier = 'site_' . $site->getIdentifier() . '_' . $siteLanguage->getLanguageId();
-                $groupedRoutes[($urlParts['scheme'] ?? '-') . ($urlParts['host'] ?? '-')][$urlParts['path'] ?? '/'][$identifier] = $route;
-            }
-        }
-        // As the {next} parameter is greedy, it needs to be ensured that the one with the
-        // most specific part matches first
-        foreach ($groupedRoutes as $groupedRoutesPerHost) {
-            krsort($groupedRoutesPerHost);
-            foreach ($groupedRoutesPerHost as $groupedRoutesPerPath) {
-                krsort($groupedRoutesPerPath);
-                foreach ($groupedRoutesPerPath as $identifier => $route) {
-                    $collection->add($identifier, $route);
-                }
-            }
-        }
-        return $collection;
-    }
-
-    /**
      * Find a site by given root page id
      *
      * @param int $rootPageId
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-85900-PseudoSiteHandling.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-85900-PseudoSiteHandling.rst
new file mode 100644 (file)
index 0000000..7688cbe
--- /dev/null
@@ -0,0 +1,27 @@
+.. include:: ../../Includes.txt
+
+======================================
+Feature: #85900 - Pseudo Site Handling
+======================================
+
+See :issue:`85900`
+
+Description
+===========
+
+The new site handling functionality has a counterpart for usages within PHP code where no site configuration
+can be found, which is named "Pseudo Site", a site without configuration.
+
+For a pseudo-site it is not possible to determine all available languages (as they are only configured in
+TypoScript), or the proper labels for the default language (as this is done in PageTSconfig), however, a
+PseudoSite or Site object (both instances of "SiteInterface") is now always attached to every Frontend or
+Backend request via a PSR-15 middleware.
+
+
+Impact
+======
+
+Extension Developers can now access a site and determine the base URL / Entry Point URL for a site, or access
+all available languages via the SiteInterface object, instead of querying sys_domain or sys_language respectively.
+
+.. index:: PHP-API
\ No newline at end of file
diff --git a/typo3/sysext/core/Tests/Unit/Site/Entity/PseudoSiteTest.php b/typo3/sysext/core/Tests/Unit/Site/Entity/PseudoSiteTest.php
new file mode 100644 (file)
index 0000000..eb7360c
--- /dev/null
@@ -0,0 +1,76 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Core\Tests\Unit\Site\Entity;
+
+/*
+ * This file is part of the TYPO3 CMS project.
+ *
+ * It is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License, either version 2
+ * of the License, or any later version.
+ *
+ * For the full copyright and license information, please read the
+ * LICENSE.txt file that was distributed with this source code.
+ *
+ * The TYPO3 project - inspiring people to share!
+ */
+
+use TYPO3\CMS\Core\Site\Entity\PseudoSite;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class PseudoSiteTest extends UnitTestCase
+{
+
+    /**
+     * @return array
+     */
+    public function pseudoSiteReturnsProperEntryPointsDataProvider()
+    {
+        return [
+            'no domain' => [
+                [],
+                ['/'],
+                '/'
+            ],
+            'invalid domain argument' => [
+                [
+                    ['domain_name' => 'not.recognized.com']
+                ],
+                ['/'],
+                '/'
+            ],
+            'regular domain given' => [
+                [
+                    ['domainName' => 'blog.example.com/download']
+                ],
+                ['//blog.example.com/download'],
+                '//blog.example.com/download'
+            ],
+            'multiple domains given' => [
+                [
+                    ['domainName' => 'www.example.com'],
+                    ['domainName' => 'blog.example.com'],
+                    ['domainName' => 'blog.example.com/food-koma'],
+                ],
+                [
+                    '//www.example.com',
+                    '//blog.example.com',
+                    '//blog.example.com/food-koma',
+                ],
+                '//www.example.com'
+            ]
+        ];
+    }
+
+    /**
+     * @test
+     * @dataProvider pseudoSiteReturnsProperEntryPointsDataProvider
+     */
+    public function pseudoSiteReturnsProperEntryPoints($sysDomainRecords, $expectedResolvedEntryPoints, $expectedFirstEntryPoint)
+    {
+        $subject = new PseudoSite(13, ['domains' => $sysDomainRecords, 'languages' => []]);
+        $this->assertSame($expectedResolvedEntryPoints, $subject->getEntryPoints());
+        $this->assertSame($expectedFirstEntryPoint, $subject->getBase());
+    }
+}
index 1fe891a..5ac1244 100644 (file)
@@ -17,12 +17,6 @@ namespace TYPO3\CMS\Frontend\Compatibility;
  */
 
 use Psr\Http\Message\ServerRequestInterface;
-use Symfony\Component\Routing\Exception\NoConfigurationException;
-use Symfony\Component\Routing\Matcher\UrlMatcher;
-use Symfony\Component\Routing\RequestContext;
-use Symfony\Component\Routing\Route;
-use Symfony\Component\Routing\RouteCollection;
-use TYPO3\CMS\Backend\Routing\Exception\ResourceNotFoundException;
 use TYPO3\CMS\Core\Cache\CacheManager;
 use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
 use TYPO3\CMS\Core\Database\ConnectionPool;
@@ -55,24 +49,6 @@ class LegacyDomainResolver implements SingletonInterface
     protected $cache;
 
     /**
-     * Whether a sys_domain like example.com should also match for my.blog.example.com
-     *
-     * @var bool
-     */
-    protected $recursiveDomainSearch;
-
-    /**
-     * @var RouteCollection
-     */
-    protected $routeCollection;
-
-    /**
-     * all entries in sys_domain
-     * @var array
-     */
-    protected $allDomainRecords;
-
-    /**
      * all entries in sys_domain grouped by page (pid)
      * @var array
      */
@@ -81,8 +57,6 @@ class LegacyDomainResolver implements SingletonInterface
     public function __construct()
     {
         $this->cache = GeneralUtility::makeInstance(CacheManager::class)->getCache('cache_core');
-        $this->recursiveDomainSearch = (bool)$GLOBALS['TYPO3_CONF_VARS']['SYS']['recursiveDomainSearch'];
-        $this->routeCollection = new RouteCollection();
         $this->populate();
     }
 
@@ -94,10 +68,7 @@ class LegacyDomainResolver implements SingletonInterface
         if ($data = $this->cache->get('legacy-domains')) {
             // Due to the nature of PhpFrontend, the `<?php` and `#` wraps have to be removed
             $data = preg_replace('/^<\?php\s*|\s*#$/', '', $data);
-            $data = unserialize($data, ['allowed_classes' => [Route::class, RouteCollection::class]]);
-            $this->routeCollection = $data['routeCollection'];
-            $this->allDomainRecords = $data['allDomainRecords'];
-            $this->groupedDomainsPerPage = $data['groupedDomainsPerPage'];
+            $this->groupedDomainsPerPage = json_decode($data, true);
         } else {
             $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_domain');
             $queryBuilder->getRestrictions()->removeAll();
@@ -109,74 +80,19 @@ class LegacyDomainResolver implements SingletonInterface
 
             while ($row = $statement->fetch()) {
                 $row['domainName'] = rtrim($row['domainName'], '/');
-                $this->allDomainRecords[(int)$row['uid']] = $row;
                 $this->groupedDomainsPerPage[(int)$row['pid']][] = $row;
-                if (!$row['hidden']) {
-                    if (strpos($row['domainName'], '/') === false) {
-                        $path = '';
-                        list($host, $port) = explode(':', $row['domainName']);
-                    } else {
-                        $urlParts = parse_url($row['domainName']);
-                        $path = trim($urlParts['path'], '/');
-                        $host = $urlParts['host'];
-                        $port = (string)$urlParts['port'];
-                    }
-                    $route = new Route(
-                        $path . '/{next}',
-                        ['pageId' => $row['pid']],
-                        array_filter(['next' => '.*', 'port' => $port]),
-                        ['utf8' => true],
-                        $host ?? ''
-                    );
-                    $this->routeCollection->add('domain_' . $row['uid'], $route);
-                }
             }
 
-            $data = [
-                'routeCollection' => $this->routeCollection,
-                'allDomainRecords' => $this->allDomainRecords,
-                'groupedDomainsPerPage' => $this->groupedDomainsPerPage
-            ];
-            $this->cache->set('legacy-domains', serialize($data), ['sys_domain'], 0);
+            $this->cache->set('legacy-domains', json_encode($this->groupedDomainsPerPage));
         }
     }
 
     /**
-     * Return the page ID (pid) of a sys_domain record, based on a request object, does the infamous
-     * "recursive domain search", to also detect if the domain is like "abc.def.example.com" even if the
-     * sys_domain entry is "example.com".
-     *
-     * @param ServerRequestInterface $request
-     * @return int page ID
+     * @return array
      */
-    public function matchRequest(ServerRequestInterface $request): int
+    public function getGroupedDomainsPerPage(): array
     {
-        if (empty($this->allDomainRecords) || count($this->routeCollection) === 0) {
-            return 0;
-        }
-        $context = new RequestContext('/', $request->getMethod(), $request->getUri()->getHost());
-        $matcher = new UrlMatcher($this->routeCollection, $context);
-        if ($this->recursiveDomainSearch) {
-            $pageUid = 0;
-            $host = explode('.', $request->getUri()->getHost());
-            while (count($host)) {
-                $context->setHost(implode('.', $host));
-                try {
-                    $result = $matcher->match($request->getUri()->getPath());
-                    return (int)$result['pageId'];
-                } catch (NoConfigurationException | ResourceNotFoundException $e) {
-                    array_shift($host);
-                }
-            }
-            return $pageUid;
-        }
-        try {
-            $result = $matcher->match($request->getUri()->getPath());
-            return (int)$result['pageId'];
-        } catch (NoConfigurationException | ResourceNotFoundException $e) {
-            // No domain record found
-        }
-        return 0;
+        return $this->groupedDomainsPerPage ?? [];
     }
 
     /**
index 19f5bff..4e4ca4d 100644 (file)
@@ -19,16 +19,14 @@ use Psr\Http\Message\ResponseInterface;
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Http\Server\MiddlewareInterface;
 use Psr\Http\Server\RequestHandlerInterface;
-use Symfony\Component\Routing\Exception\ResourceNotFoundException;
-use Symfony\Component\Routing\Matcher\UrlMatcher;
-use Symfony\Component\Routing\RequestContext;
 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
-use TYPO3\CMS\Core\Exception\SiteNotFoundException;
+use TYPO3\CMS\Core\Routing\SiteMatcher;
+use TYPO3\CMS\Core\Site\Entity\PseudoSite;
 use TYPO3\CMS\Core\Site\Entity\Site;
+use TYPO3\CMS\Core\Site\Entity\SiteInterface;
 use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
 use TYPO3\CMS\Core\Site\SiteFinder;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
-use TYPO3\CMS\Frontend\Compatibility\LegacyDomainResolver;
 use TYPO3\CMS\Frontend\Controller\ErrorController;
 use TYPO3\CMS\Frontend\Page\PageAccessFailureReasons;
 
@@ -44,17 +42,16 @@ use TYPO3\CMS\Frontend\Page\PageAccessFailureReasons;
 class SiteResolver implements MiddlewareInterface
 {
     /**
-     * @var SiteFinder
+     * @var SiteMatcher
      */
-    protected $finder;
+    protected $matcher;
 
-    /**
-     * Injects necessary objects
-     * @param SiteFinder|null $finder
-     */
-    public function __construct(SiteFinder $finder = null)
+    public function __construct(SiteMatcher $matcher = null)
     {
-        $this->finder = $finder ?? GeneralUtility::makeInstance(SiteFinder::class);
+        $this->matcher = $matcher ?? GeneralUtility::makeInstance(
+            SiteMatcher::class,
+            GeneralUtility::makeInstance(SiteFinder::class)
+        );
     }
 
     /**
@@ -66,64 +63,18 @@ class SiteResolver implements MiddlewareInterface
      */
     public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
     {
-        $site = null;
-        $language = null;
-
-        $pageId = $request->getQueryParams()['id'] ?? $request->getParsedBody()['id'] ?? 0;
-        $languageId = $request->getQueryParams()['L'] ?? $request->getParsedBody()['L'] ?? null;
-
-        // 1. Check if we have a _GET/_POST parameter for "id", then a site information can be resolved based.
-        if ($pageId > 0 && $languageId !== null) {
-            // Loop over the whole rootline without permissions to get the actual site information
-            try {
-                $site = $this->finder->getSiteByPageId((int)$pageId);
-                $language = $site->getLanguageById((int)$languageId);
-                // language is hidden but also not visible to the BE user, this needs to fail
-                if ($language && !$this->isLanguageEnabled($language, $GLOBALS['BE_USER'] ?? null)) {
-                    $request = $request->withAttribute('site', $site);
-                    return GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
-                        $request,
-                        'Page is not available in the requested language.',
-                        ['code' => PageAccessFailureReasons::LANGUAGE_NOT_AVAILABLE]
-                    );
-                }
-            } catch (SiteNotFoundException $e) {
-                // No site found by ID
-            }
-        }
+        $routeResult = $this->matcher->matchRequest($request);
+        $site = $routeResult['site'] ?? null;
+        $language = $routeResult['language'] ?? null;
 
-        // 2. Check if there is a site language, if not, do "Site Routing"
-        if (!($language instanceof SiteLanguage)) {
-            $collection = $this->finder->getRouteCollectionForAllSites();
-            // This part will likely be extracted into a separate class that builds a context out of a PSR-7 request
-            // something like $result = SiteRouter->matchRequest($psr7Request);
-            $context = new RequestContext(
-                '',
-                $request->getMethod(),
-                $request->getUri()->getHost(),
-                $request->getUri()->getScheme(),
-                // Ports are only necessary for URL generation in Symfony which is not used by TYPO3
-                80,
-                443,
-                $request->getUri()->getPath()
+        // language is found, and hidden but also not visible to the BE user, this needs to fail
+        if ($language instanceof SiteLanguage && !$this->isLanguageEnabled($language, $GLOBALS['BE_USER'] ?? null)) {
+            $request = $request->withAttribute('site', $site);
+            return GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
+                $request,
+                'Page is not available in the requested language.',
+                ['code' => PageAccessFailureReasons::LANGUAGE_NOT_AVAILABLE]
             );
-            $matcher = new UrlMatcher($collection, $context);
-            try {
-                $result = $matcher->match($request->getUri()->getPath());
-                $site = $result['site'];
-                $language = $result['language'];
-                // language is found, and hidden but also not visible to the BE user, this needs to fail
-                if ($language && !$this->isLanguageEnabled($language, $GLOBALS['BE_USER'] ?? null)) {
-                    $request = $request->withAttribute('site', $site);
-                    return GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
-                        $request,
-                        'Page is not available in the requested language.',
-                        ['code' => PageAccessFailureReasons::LANGUAGE_NOT_AVAILABLE]
-                    );
-                }
-            } catch (ResourceNotFoundException $e) {
-                // No site found
-            }
         }
 
         // Add language+site information to the PSR-7 request object.
@@ -138,14 +89,16 @@ class SiteResolver implements MiddlewareInterface
             // At this point, we later get further route modifiers
             // for bw-compat we update $GLOBALS[TYPO3_REQUEST] to be used later in TSFE.
             $GLOBALS['TYPO3_REQUEST'] = $request;
+        } elseif ($site instanceof PseudoSite) {
+            $request = $request->withAttribute('site', $site);
+            // At this point, we later get further route modifiers
+            // for bw-compat we update $GLOBALS[TYPO3_REQUEST] to be used later in TSFE.
+            $GLOBALS['TYPO3_REQUEST'] = $request;
         }
 
         // Now resolve the root page of the site, the page_id of the current domain
-        if ($site instanceof Site) {
+        if ($site instanceof SiteInterface) {
             $GLOBALS['TSFE']->domainStartPage = $site->getRootPageId();
-        } else {
-            $GLOBALS['TSFE']->domainStartPage = GeneralUtility::makeInstance(LegacyDomainResolver::class)
-                ->matchRequest($request);
         }
 
         return $handler->handle($request);
index b1aaa06..c2834fd 100644 (file)
@@ -22,6 +22,7 @@ use Psr\Http\Server\RequestHandlerInterface;
 use TYPO3\CMS\Core\Http\JsonResponse;
 use TYPO3\CMS\Core\Http\NullResponse;
 use TYPO3\CMS\Core\Http\ServerRequest;
+use TYPO3\CMS\Core\Routing\SiteMatcher;
 use TYPO3\CMS\Core\Site\Entity\Site;
 use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
 use TYPO3\CMS\Core\Site\SiteFinder;
@@ -102,7 +103,7 @@ class SiteResolverTest extends UnitTestCase
         ]);
 
         $request = new ServerRequest($incomingUrl, 'GET');
-        $subject = new SiteResolver($this->siteFinder);
+        $subject = new SiteResolver(new SiteMatcher($this->siteFinder));
         $response = $subject->process($request, $this->siteFoundRequestHandler);
         if ($response instanceof NullResponse) {
             $this->fail('No site configuration found in URL ' . $incomingUrl . '.');
@@ -153,7 +154,7 @@ class SiteResolverTest extends UnitTestCase
         ]);
 
         $request = new ServerRequest($incomingUrl, 'GET');
-        $subject = new SiteResolver($this->siteFinder);
+        $subject = new SiteResolver(new SiteMatcher($this->siteFinder));
         $response = $subject->process($request, $this->siteFoundRequestHandler);
         if ($response instanceof NullResponse) {
             $this->fail('No site configuration found in URL ' . $incomingUrl . '.');
@@ -242,7 +243,7 @@ class SiteResolverTest extends UnitTestCase
         ]);
 
         $request = new ServerRequest($incomingUrl, 'GET');
-        $subject = new SiteResolver($this->siteFinder);
+        $subject = new SiteResolver(new SiteMatcher($this->siteFinder));
         $response = $subject->process($request, $this->siteFoundRequestHandler);
         if ($response instanceof NullResponse) {
             $this->fail('No site configuration found in URL ' . $incomingUrl . '.');
@@ -360,7 +361,7 @@ class SiteResolverTest extends UnitTestCase
         ]);
 
         $request = new ServerRequest($incomingUrl, 'GET');
-        $subject = new SiteResolver($this->siteFinder);
+        $subject = new SiteResolver(new SiteMatcher($this->siteFinder));
         $response = $subject->process($request, $this->siteFoundRequestHandler);
         if ($response instanceof NullResponse) {
             $this->fail('No site configuration found in URL ' . $incomingUrl . '.');
@@ -408,12 +409,12 @@ class SiteResolverTest extends UnitTestCase
 
         // Reqest to default page
         $request = new ServerRequest('https://twenty.one/en/pilots/', 'GET');
-        $subject = new SiteResolver($this->siteFinder);
+        $subject = new SiteResolver(new SiteMatcher($this->siteFinder));
         $response = $subject->process($request, $this->siteFoundRequestHandler);
         $this->assertEquals(404, $response->getStatusCode());
 
         $request = new ServerRequest('https://twenty.one/fr/pilots/', 'GET');
-        $subject = new SiteResolver($this->siteFinder);
+        $subject = new SiteResolver(new SiteMatcher($this->siteFinder));
         $response = $subject->process($request, $this->siteFoundRequestHandler);
         $this->assertEquals(200, $response->getStatusCode());
     }