[FEATURE] Use symfony/routing for Site Resolving 51/57851/17
authorBenni Mack <benni@typo3.org>
Fri, 10 Aug 2018 11:38:21 +0000 (13:38 +0200)
committerAndreas Wolf <andreas.wolf@typo3.org>
Sat, 11 Aug 2018 18:28:38 +0000 (20:28 +0200)
Use symfony/router 4.1 for resolving a site based on the
current request.

This actually removes some simple resolving built previously
by myself, which was stupid code to detect a site base.

With the symfony/routing component, it is now possible to
have site base prefixes without a scheme (just the domain)
and allow to handle both prefixes. It is also possible to
just add "/site1" and "/site2" as base for domains as
well, allowing to listen to any incoming domain.

As this Routing component will be used for further
page-based routing, the introduced symfony-specific
code might change and encapsulated in other places.

With this patch we now require symfony 4.1 components
or higher, as symfony/routing became fast with 4.1,
and symfony/routing 4.1 is incompatible with various 3.x
components we use. Composer-based installations might
not be able to upgrade, if they have a strong
dependency on a lower symfony version.

The composer command used:
    composer req symfony/console:^4.1 symfony/expression-language:^4.1 \
    symfony/finder:^4.1 symfony/routing:^4.1 symfony/yaml:^4.1 \
    --update-with-dependencies

Resolves: #85719
Resolves: #85165
Releases: master
Change-Id: If21ff3581552ca98af28739a76236a160508f16d
Reviewed-on: https://review.typo3.org/57851
Tested-by: TYPO3com <no-reply@typo3.com>
Tested-by: Daniel Siepmann <daniel.siepmann@typo3.org>
Reviewed-by: Christian Kuhn <lolli@schwarzbu.ch>
Tested-by: Christian Kuhn <lolli@schwarzbu.ch>
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
Reviewed-by: Andreas Wolf <andreas.wolf@typo3.org>
Tested-by: Andreas Wolf <andreas.wolf@typo3.org>
12 files changed:
composer.json
composer.lock
typo3/sysext/core/Classes/Site/Entity/Site.php
typo3/sysext/core/Classes/Site/SiteFinder.php
typo3/sysext/core/Documentation/Changelog/master/Feature-85719-AllowSitesWithoutSchemeOrDomain.rst [new file with mode: 0644]
typo3/sysext/core/Documentation/Changelog/master/Important-85719-PHPPackagesSymfonyComponentsRequirementsRaisedToSymfony41.rst [new file with mode: 0644]
typo3/sysext/core/Tests/Unit/Site/Entity/SiteTest.php
typo3/sysext/core/composer.json
typo3/sysext/form/composer.json
typo3/sysext/frontend/Classes/Middleware/SiteResolver.php
typo3/sysext/frontend/Tests/Unit/Middleware/SiteResolverTest.php [new file with mode: 0644]
typo3/sysext/install/composer.json

index 46555e5..f85ec9a 100644 (file)
                "psr/http-server-middleware": "^1.0",
                "psr/log": "~1.0.0",
                "swiftmailer/swiftmailer": "~5.4.5",
-               "symfony/console": "^2.7 || ^3.0 || ^4.0",
-               "symfony/expression-language": "^3.4 || ^4.0",
-               "symfony/finder": "^2.7 || ^3.0 || ^4.0",
+               "symfony/console": "^4.1",
+               "symfony/expression-language": "^4.1",
+               "symfony/finder": "^4.1",
                "symfony/polyfill-intl-icu": "^1.6",
                "symfony/polyfill-mbstring": "^1.2",
-               "symfony/yaml": "^2.7 || ^3.0 || ^4.0",
+               "symfony/routing": "^4.1",
+               "symfony/yaml": "^4.1",
                "typo3/class-alias-loader": "^1.0",
                "typo3/cms-cli": "^2.0",
                "typo3/cms-composer-installers": "^2.0",
index 880e0cb..dd5480a 100644 (file)
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "a4daba415dd0d0cbb6faf3e63e96975c",
+    "content-hash": "82ae59b378b09a7fa1eb12fe059d89a4",
     "packages": [
         {
             "name": "cogpowered/finediff",
         },
         {
             "name": "symfony/cache",
-            "version": "v4.1.2",
+            "version": "v4.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/cache.git",
-                "reference": "42191caaf21ab7be0eb623d6c572e0b2932a8880"
+                "reference": "c666a5bbfeb1fe05c7b91d46810f405c8bea14cf"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/cache/zipball/42191caaf21ab7be0eb623d6c572e0b2932a8880",
-                "reference": "42191caaf21ab7be0eb623d6c572e0b2932a8880",
+                "url": "https://api.github.com/repos/symfony/cache/zipball/c666a5bbfeb1fe05c7b91d46810f405c8bea14cf",
+                "reference": "c666a5bbfeb1fe05c7b91d46810f405c8bea14cf",
                 "shasum": ""
             },
             "require": {
                 "caching",
                 "psr6"
             ],
-            "time": "2018-07-03T17:58:50+00:00"
+            "time": "2018-07-26T11:24:31+00:00"
         },
         {
             "name": "symfony/console",
-            "version": "v3.2.2",
+            "version": "v4.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/console.git",
-                "reference": "4f9e449e76996adf310498a8ca955c6deebe29dd"
+                "reference": "ca80b8ced97cf07390078b29773dc384c39eee1f"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/console/zipball/4f9e449e76996adf310498a8ca955c6deebe29dd",
-                "reference": "4f9e449e76996adf310498a8ca955c6deebe29dd",
+                "url": "https://api.github.com/repos/symfony/console/zipball/ca80b8ced97cf07390078b29773dc384c39eee1f",
+                "reference": "ca80b8ced97cf07390078b29773dc384c39eee1f",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5.9",
-                "symfony/debug": "~2.8|~3.0",
+                "php": "^7.1.3",
                 "symfony/polyfill-mbstring": "~1.0"
             },
+            "conflict": {
+                "symfony/dependency-injection": "<3.4",
+                "symfony/process": "<3.3"
+            },
             "require-dev": {
                 "psr/log": "~1.0",
-                "symfony/event-dispatcher": "~2.8|~3.0",
-                "symfony/filesystem": "~2.8|~3.0",
-                "symfony/process": "~2.8|~3.0"
+                "symfony/config": "~3.4|~4.0",
+                "symfony/dependency-injection": "~3.4|~4.0",
+                "symfony/event-dispatcher": "~3.4|~4.0",
+                "symfony/lock": "~3.4|~4.0",
+                "symfony/process": "~3.4|~4.0"
             },
             "suggest": {
-                "psr/log": "For using the console logger",
+                "psr/log-implementation": "For using the console logger",
                 "symfony/event-dispatcher": "",
-                "symfony/filesystem": "",
+                "symfony/lock": "",
                 "symfony/process": ""
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.2-dev"
+                    "dev-master": "4.1-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Symfony Console Component",
             "homepage": "https://symfony.com",
-            "time": "2017-01-08T20:47:33+00:00"
-        },
-        {
-            "name": "symfony/debug",
-            "version": "v3.2.2",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/debug.git",
-                "reference": "810ba5c1c5352a4ddb15d4719e8936751dff0b05"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/debug/zipball/810ba5c1c5352a4ddb15d4719e8936751dff0b05",
-                "reference": "810ba5c1c5352a4ddb15d4719e8936751dff0b05",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.5.9",
-                "psr/log": "~1.0"
-            },
-            "conflict": {
-                "symfony/http-kernel": ">=2.3,<2.3.24|~2.4.0|>=2.5,<2.5.9|>=2.6,<2.6.2"
-            },
-            "require-dev": {
-                "symfony/class-loader": "~2.8|~3.0",
-                "symfony/http-kernel": "~2.8|~3.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "3.2-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Component\\Debug\\": ""
-                },
-                "exclude-from-classmap": [
-                    "/Tests/"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Fabien Potencier",
-                    "email": "fabien@symfony.com"
-                },
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                }
-            ],
-            "description": "Symfony Debug Component",
-            "homepage": "https://symfony.com",
-            "time": "2017-01-02T20:32:22+00:00"
+            "time": "2018-07-26T11:24:31+00:00"
         },
         {
             "name": "symfony/expression-language",
-            "version": "v4.1.2",
+            "version": "v4.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/expression-language.git",
-                "reference": "81653bbb8e0feff271bebfdea492386f1c75c098"
+                "reference": "065bba63c61c96fd2d4fbd01b28de058e6f8779a"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/expression-language/zipball/81653bbb8e0feff271bebfdea492386f1c75c098",
-                "reference": "81653bbb8e0feff271bebfdea492386f1c75c098",
+                "url": "https://api.github.com/repos/symfony/expression-language/zipball/065bba63c61c96fd2d4fbd01b28de058e6f8779a",
+                "reference": "065bba63c61c96fd2d4fbd01b28de058e6f8779a",
                 "shasum": ""
             },
             "require": {
             ],
             "description": "Symfony ExpressionLanguage Component",
             "homepage": "https://symfony.com",
-            "time": "2018-06-21T11:15:46+00:00"
+            "time": "2018-07-26T09:10:45+00:00"
         },
         {
             "name": "symfony/finder",
-            "version": "v3.2.2",
+            "version": "v4.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/finder.git",
-                "reference": "8c71141cae8e2957946b403cc71a67213c0380d6"
+                "reference": "e162f1df3102d0b7472805a5a9d5db9fcf0a8068"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/finder/zipball/8c71141cae8e2957946b403cc71a67213c0380d6",
-                "reference": "8c71141cae8e2957946b403cc71a67213c0380d6",
+                "url": "https://api.github.com/repos/symfony/finder/zipball/e162f1df3102d0b7472805a5a9d5db9fcf0a8068",
+                "reference": "e162f1df3102d0b7472805a5a9d5db9fcf0a8068",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5.9"
+                "php": "^7.1.3"
             },
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.2-dev"
+                    "dev-master": "4.1-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Symfony Finder Component",
             "homepage": "https://symfony.com",
-            "time": "2017-01-02T20:32:22+00:00"
+            "time": "2018-07-26T11:24:31+00:00"
         },
         {
             "name": "symfony/intl",
             "time": "2017-11-16T15:24:32+00:00"
         },
         {
+            "name": "symfony/polyfill-ctype",
+            "version": "v1.8.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-ctype.git",
+                "reference": "7cc359f1b7b80fc25ed7796be7d96adc9b354bae"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/7cc359f1b7b80fc25ed7796be7d96adc9b354bae",
+                "reference": "7cc359f1b7b80fc25ed7796be7d96adc9b354bae",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.8-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Polyfill\\Ctype\\": ""
+                },
+                "files": [
+                    "bootstrap.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                },
+                {
+                    "name": "Gert de Pagter",
+                    "email": "BackEndTea@gmail.com"
+                }
+            ],
+            "description": "Symfony polyfill for ctype functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "ctype",
+                "polyfill",
+                "portable"
+            ],
+            "time": "2018-04-30T19:57:29+00:00"
+        },
+        {
             "name": "symfony/polyfill-intl-icu",
             "version": "v1.6.0",
             "source": {
             "time": "2016-11-14T01:06:16+00:00"
         },
         {
+            "name": "symfony/routing",
+            "version": "v4.1.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/routing.git",
+                "reference": "6912cfebc0ea4e7a46fdd15c9bd1f427dd39ff1b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/routing/zipball/6912cfebc0ea4e7a46fdd15c9bd1f427dd39ff1b",
+                "reference": "6912cfebc0ea4e7a46fdd15c9bd1f427dd39ff1b",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1.3"
+            },
+            "conflict": {
+                "symfony/config": "<3.4",
+                "symfony/dependency-injection": "<3.4",
+                "symfony/yaml": "<3.4"
+            },
+            "require-dev": {
+                "doctrine/annotations": "~1.0",
+                "psr/log": "~1.0",
+                "symfony/config": "~3.4|~4.0",
+                "symfony/dependency-injection": "~3.4|~4.0",
+                "symfony/expression-language": "~3.4|~4.0",
+                "symfony/http-foundation": "~3.4|~4.0",
+                "symfony/yaml": "~3.4|~4.0"
+            },
+            "suggest": {
+                "doctrine/annotations": "For using the annotation loader",
+                "symfony/config": "For using the all-in-one router or any loader",
+                "symfony/dependency-injection": "For loading routes from a service",
+                "symfony/expression-language": "For using expression matching",
+                "symfony/http-foundation": "For using a Symfony Request object",
+                "symfony/yaml": "For using the YAML loader"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.1-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Routing\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony Routing Component",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "router",
+                "routing",
+                "uri",
+                "url"
+            ],
+            "time": "2018-07-26T11:24:31+00:00"
+        },
+        {
             "name": "symfony/yaml",
-            "version": "v3.2.2",
+            "version": "v4.1.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/yaml.git",
-                "reference": "50eadbd7926e31842893c957eca362b21592a97d"
+                "reference": "46bc69aa91fc4ab78a96ce67873a6b0c148fd48c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/yaml/zipball/50eadbd7926e31842893c957eca362b21592a97d",
-                "reference": "50eadbd7926e31842893c957eca362b21592a97d",
+                "url": "https://api.github.com/repos/symfony/yaml/zipball/46bc69aa91fc4ab78a96ce67873a6b0c148fd48c",
+                "reference": "46bc69aa91fc4ab78a96ce67873a6b0c148fd48c",
                 "shasum": ""
             },
             "require": {
-                "php": ">=5.5.9"
+                "php": "^7.1.3",
+                "symfony/polyfill-ctype": "~1.8"
+            },
+            "conflict": {
+                "symfony/console": "<3.4"
             },
             "require-dev": {
-                "symfony/console": "~2.8|~3.0"
+                "symfony/console": "~3.4|~4.0"
             },
             "suggest": {
                 "symfony/console": "For validating YAML files using the lint command"
             "type": "library",
             "extra": {
                 "branch-alias": {
-                    "dev-master": "3.2-dev"
+                    "dev-master": "4.1-dev"
                 }
             },
             "autoload": {
             ],
             "description": "Symfony Yaml Component",
             "homepage": "https://symfony.com",
-            "time": "2017-01-03T13:51:32+00:00"
+            "time": "2018-07-26T11:24:31+00:00"
         },
         {
             "name": "typo3/class-alias-loader",
             "time": "2018-01-18T22:19:33+00:00"
         },
         {
-            "name": "symfony/polyfill-ctype",
-            "version": "v1.8.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/symfony/polyfill-ctype.git",
-                "reference": "7cc359f1b7b80fc25ed7796be7d96adc9b354bae"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/7cc359f1b7b80fc25ed7796be7d96adc9b354bae",
-                "reference": "7cc359f1b7b80fc25ed7796be7d96adc9b354bae",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=5.3.3"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.8-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Symfony\\Polyfill\\Ctype\\": ""
-                },
-                "files": [
-                    "bootstrap.php"
-                ]
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "Symfony Community",
-                    "homepage": "https://symfony.com/contributors"
-                },
-                {
-                    "name": "Gert de Pagter",
-                    "email": "BackEndTea@gmail.com"
-                }
-            ],
-            "description": "Symfony polyfill for ctype functions",
-            "homepage": "https://symfony.com",
-            "keywords": [
-                "compatibility",
-                "ctype",
-                "polyfill",
-                "portable"
-            ],
-            "time": "2018-04-30T19:57:29+00:00"
-        },
-        {
             "name": "symfony/polyfill-php70",
             "version": "v1.8.0",
             "source": {
index 54fe12c..cdbcc89 100644 (file)
@@ -74,27 +74,36 @@ class Site implements SiteInterface
         $this->identifier = $identifier;
         $this->rootPageId = $rootPageId;
         $this->configuration = $configuration;
-        $configuration['languages'] = $configuration['languages'] ?: [0 => [
-            'languageId' => 0,
-            'title' => 'Default',
-            'navigationTitle' => '',
-            'typo3Language' => 'default',
-            'flag' => 'us',
-            'locale' => 'en_US.UTF-8',
-            'iso-639-1' => 'en',
-            'hreflang' => 'en-US',
-            'direction' => '',
-        ]];
-        $this->base = $configuration['base'] ?? '';
+        $configuration['languages'] = $configuration['languages'] ?: [
+            0 => [
+                'languageId' => 0,
+                'title' => 'Default',
+                'navigationTitle' => '',
+                'typo3Language' => 'default',
+                'flag' => 'us',
+                'locale' => 'en_US.UTF-8',
+                'iso-639-1' => 'en',
+                'hreflang' => 'en-US',
+                'direction' => '',
+            ]
+        ];
+        $this->base = $this->sanitizeBaseUrl($configuration['base'] ?? '');
         foreach ($configuration['languages'] as $languageConfiguration) {
             $languageUid = (int)$languageConfiguration['languageId'];
-            $base = '/';
+            // site language has defined its own base, this is the case most of the time.
             if (!empty($languageConfiguration['base'])) {
                 $base = $languageConfiguration['base'];
-            }
-            $baseParts = parse_url($base);
-            if (empty($baseParts['scheme'])) {
-                $base = rtrim($this->base, '/') . '/' . ltrim($base, '/');
+                $base = $this->sanitizeBaseUrl($base);
+                $baseParts = parse_url($base);
+                // no host given by the language-specific base, so lets prefix the main site base
+                if (empty($baseParts['scheme']) && empty($baseParts['host'])) {
+                    $base = rtrim($this->base, '/') . '/' . ltrim($base, '/');
+                    $base = $this->sanitizeBaseUrl($base);
+                }
+            } else {
+                // Language configuration does not have a base defined
+                // So the main site base is used (usually done for default languages)
+                $base = $this->sanitizeBaseUrl(rtrim($this->base, '/') . '/');
             }
             $this->languages[$languageUid] = new SiteLanguage(
                 $this,
@@ -224,4 +233,29 @@ class Site implements SiteInterface
             1522495954
         );
     }
+
+    /**
+     * 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;
+    }
 }
index fb24580..17b4a99 100644 (file)
@@ -16,8 +16,9 @@ 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\Context\Context;
 use TYPO3\CMS\Core\Core\Environment;
 use TYPO3\CMS\Core\Exception\Page\PageNotFoundException;
 use TYPO3\CMS\Core\Exception\SiteNotFoundException;
@@ -67,22 +68,44 @@ class SiteFinder
     }
 
     /**
-     * Get a list of all configured base uris of all sites
+     * Returns a Symfony RouteCollection containing all routes to all sites.
      *
-     * @return array
+     * {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 getBaseUris(): array
+    public function getRouteCollectionForAllSites(): RouteCollection
     {
-        $baseUrls = [];
+        $collection = new RouteCollection();
+        $groupedRoutes = [];
         foreach ($this->sites as $site) {
-            foreach ($site->getLanguages() as $language) {
-                $baseUrls[$language->getBase()] = $language;
-                if ($language->getLanguageId() === 0) {
-                    $baseUrls[$site->getBase()] = $language;
+            foreach ($site->getLanguages() as $siteLanguage) {
+                $urlParts = parse_url($siteLanguage->getBase());
+                $route = new Route(
+                    ($urlParts['path'] ?? '/') . '{next}',
+                    ['next' => '', 'site' => $site, 'language' => $siteLanguage],
+                    array_filter(['next' => '.*', 'port' => $urlParts['port'] ?? null]),
+                    ['utf8' => true],
+                    $urlParts['host'] ?? '',
+                    !empty($urlParts['scheme']) ? [$urlParts['scheme']] : null
+                );
+                $identifier = 'site_' . $site->getIdentifier() . '_' . $siteLanguage->getLanguageId();
+                $groupedRoutes[$urlParts['host'] ?? 0][$urlParts['path'] ?? 0][$identifier] = $route;
+            }
+        }
+        // As the {next} parameter is greedy, it needs to be ensured that the one with the most specific part
+        // matches last
+        foreach ($groupedRoutes as $groupedRoutesPerHost) {
+            krsort($groupedRoutesPerHost);
+            foreach ($groupedRoutesPerHost as $groupedRoutesPerPath) {
+                krsort($groupedRoutesPerPath);
+                foreach ($groupedRoutesPerPath as $identifier => $route) {
+                    $collection->add($identifier, $route);
                 }
             }
         }
-        return $baseUrls;
+        return $collection;
     }
 
     /**
@@ -101,28 +124,6 @@ class SiteFinder
     }
 
     /**
-     * Get a site language by given base URI
-     *
-     * @param string $uri
-     * @return mixed|null
-     */
-    public function getSiteLanguageByBase(string $uri)
-    {
-        $baseUris = $this->getBaseUris();
-        $bestMatchedUri = null;
-        foreach ($baseUris as $base => $language) {
-            if (strpos($uri, $base) === 0 && strlen($bestMatchedUri ?? '') < strlen($base)) {
-                $bestMatchedUri = $base;
-            }
-        }
-        $siteLanguage = $baseUris[$bestMatchedUri] ?? null;
-        if ($siteLanguage instanceof Site) {
-            $siteLanguage = $siteLanguage->getLanguageById(0);
-        }
-        return $siteLanguage;
-    }
-
-    /**
      * Find a site by given identifier
      *
      * @param string $identifier
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Feature-85719-AllowSitesWithoutSchemeOrDomain.rst b/typo3/sysext/core/Documentation/Changelog/master/Feature-85719-AllowSitesWithoutSchemeOrDomain.rst
new file mode 100644 (file)
index 0000000..6ad8dbe
--- /dev/null
@@ -0,0 +1,40 @@
+.. include:: ../../Includes.txt
+
+======================================================
+Feature: #85719 - Allow sites without scheme or domain
+======================================================
+
+See :issue:`85719`
+
+Description
+===========
+
+Since the inception of site handling, the definition of a site base - the URL prefix - was limited to
+only allow a full URI with scheme (HTTP/HTTPS) and domain. This didn't allow to run TYPO3 on multiple
+domains, basically limiting the URL-resolving ("Site Routing") compared to previous URL handling
+solutions in the past.
+
+A new site routing based on symfony/routing component allows to have a flexible routing based on
+specific schemes.
+
+
+Impact
+======
+
+It is now possible to set a site base prefix to just "/site1" and "/site2" or "www.mydomain.com" instead
+of entering a full URI.
+
+This allows to have a Site base e.g. `www.mydomain.com` to be detected with http and https protocols,
+although it is recommended to do a HTTP to HTTPS redirect either on the webserver level, via a
+.htaccess rewrite rule, or by adding a redirect in TYPO3.
+
+Please also note that this better flexibility will also introduce side-effects when having multiple sites with mixed configuration settings as Site base:
+
+Site 1: `/mysite/`
+Site 2: `www.mydomain.com`
+
+will be unspecific when detecting a URL like `www.mydomain/mysite/` and can lead to side-effects.
+
+In this case, it is necessary by the Site Administrator to define unique Site base prefixes.
+
+.. index:: Frontend
\ No newline at end of file
diff --git a/typo3/sysext/core/Documentation/Changelog/master/Important-85719-PHPPackagesSymfonyComponentsRequirementsRaisedToSymfony41.rst b/typo3/sysext/core/Documentation/Changelog/master/Important-85719-PHPPackagesSymfonyComponentsRequirementsRaisedToSymfony41.rst
new file mode 100644 (file)
index 0000000..7eb2366
--- /dev/null
@@ -0,0 +1,33 @@
+.. include:: ../../Includes.txt
+
+=======================================================================================
+Important: #85719 - PHP Packages: Symfony Components requirements raised to Symfony 4.1
+=======================================================================================
+
+See :issue:`85719`
+
+Description
+===========
+
+Due to the introduction of the PHP requirement of `symfony/routing` with a minimum requirement of
+version 4.1, all other Symfony Components have been raised to have at least 4.1 as well.
+
+This includes the following symfony components:
+* symfony/finder
+* symfony/console
+* symfony/yaml
+* symfony/expression-language
+
+This is due to the usage of `symfony/routing`, which is a must-have for TYPO3 for Route Matching,
+which has been heavily improved in version 4.1 performance-wise. As TYPO3 should have the best experience
+with routing, it is critical to use at least 4.1, and no version lower than that.
+
+If a composer-based TYPO3 installation depends on a package that is not compatible with
+symfony components lower than 4.1, it is not possible to upgrade TYPO3 to v9.4 without fixing other
+requirements.
+
+In this case, evaluate if other third-party packages can be upgraded to be compatible with
+Symfony 4.1 components and require newer versions of these packages, or open up a support ticket
+in the respective package project website to ensure compatibility with newer symfony versions.
+
+.. index:: PHP-API
\ No newline at end of file
index 91a697c..c3abe54 100644 (file)
@@ -16,7 +16,7 @@ namespace TYPO3\CMS\Core\Tests\Unit\Site\Entity;
  * The TYPO3 project - inspiring people to share!
  */
 
-use TYPO3\CMS\Backend\Utility\BackendUtility;
+use TYPO3\CMS\Core\Crypto\Random;
 use TYPO3\CMS\Core\Error\PageErrorHandler\FluidPageErrorHandler;
 use TYPO3\CMS\Core\Error\PageErrorHandler\InvalidPageErrorHandlerException;
 use TYPO3\CMS\Core\Error\PageErrorHandler\PageContentErrorHandler;
@@ -27,6 +27,100 @@ use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
 
 class SiteTest extends UnitTestCase
 {
+    public function getBaseReturnsProperUriDataProvider()
+    {
+        return [
+            'URL with scheme and domain' => ['https://www.typo3.org', 'https://www.typo3.org'],
+            'URL with scheme and domain and path' => ['https://www.typo3.org/howdy', 'https://www.typo3.org/howdy'],
+            'URL with scheme and domain and path with trailing slash' => ['https://www.typo3.org/howdy/', 'https://www.typo3.org/howdy/'],
+            'URL without scheme, but with domain' => ['www.typo3.org', '//www.typo3.org'],
+            'URL without scheme, but with domain and path' => ['www.typo3.org/partner', '//www.typo3.org/partner'],
+            'URL without scheme, but with domain and path and trailing slash' => ['www.typo3.org/partner/', '//www.typo3.org/partner/'],
+            'URL without scheme and domain but with absolute path' => ['/partner', '/partner'],
+            'URL without scheme and domain but with absolute path and trailing slash' => ['/partner/', '/partner/'],
+            'URL without scheme, domain but with random path receives a scheme divider' => ['partner/', '/partner/'],
+            'URL with ID query parameter' => ['/partner/?id=nice-to-see-you', '/partner/?id=nice-to-see-you'],
+            'URL with unknown query parameter' => ['/partner/?in-crime=nice-to-see-you', '/partner/?in-crime=nice-to-see-you'],
+        ];
+    }
+
+    /**
+     * @test
+     * @dataProvider getBaseReturnsProperUriDataProvider
+     */
+    public function getBaseReturnsProperUri($input, $expected)
+    {
+        $subject = new Site('all-your-base-belongs-to-us', 13, [
+            'base' => $input,
+            'languages' => []
+        ]);
+        $this->assertEquals($expected, $subject->getBase());
+    }
+
+    /**
+     * Consists of three parts:
+     * - input "site" base
+     * - input "site language" base
+     * - expected "site language" base after it is glued together
+     */
+    public function getBaseForSiteLanguageReturnsProperUriDataProvider()
+    {
+        return [
+            'Language as a regular path segment' => [
+                'https://www.typo3.org',
+                'en',
+                'https://www.typo3.org/en',
+            ],
+            'Language with two path segments' => [
+                'https://www.typo3.org',
+                'us/en',
+                'https://www.typo3.org/us/en',
+            ],
+            'Site base is added to absolute path segment' => [
+                'https://www.typo3.com/microsites/',
+                '/onboarding/',
+                'https://www.typo3.com/microsites/onboarding/',
+            ],
+            'Site base is prefixed to absolute path segment' => [
+                'https://www.typo3.com/microsites/',
+                'onboarding/',
+                'https://www.typo3.com/microsites/onboarding/',
+            ],
+            'Language with domain and scheme, do not care about site base' => [
+                'https://www.typo3.org',
+                'https://www.typo3.it',
+                'https://www.typo3.it',
+            ],
+            'Language with domain but no scheme, do not care about site base' => [
+                'blabla.car',
+                'www.typo3.fr',
+                '//www.typo3.fr',
+            ],
+        ];
+    }
+
+    /**
+     * This test shows that the a base from a site language is properly "inheriting" the base
+     * from a site if it isn't absolute.
+     *
+     * @test
+     * @dataProvider getBaseForSiteLanguageReturnsProperUriDataProvider
+     */
+    public function getBaseForSiteLanguageReturnsProperUri($siteBase, $languageBase, $expected)
+    {
+        $subject = new Site('all-of-base', 13, [
+            'base' => $siteBase,
+            'languages' => [
+                [
+                    'languageId' => 0,
+                    'base' => $languageBase,
+                    'locale' => 'it_IT.UTF-8',
+                ]
+            ]
+        ]);
+        $this->assertEquals($expected, $subject->getLanguageById(0)->getBase());
+    }
+
     /**
      * @test
      */
@@ -68,14 +162,14 @@ class SiteTest extends UnitTestCase
     {
         $this->expectException(InvalidPageErrorHandlerException::class);
         $this->expectExceptionCode(1527432330);
-        $this->expectExceptionMessage('The configured error handler "' . BackendUtility::class . '" for status code 404 must implement the PageErrorHandlerInterface.');
+        $this->expectExceptionMessage('The configured error handler "' . Random::class . '" for status code 404 must implement the PageErrorHandlerInterface.');
         $subject = new Site('aint-misbehaving', 13, [
             'languages' => [],
             'errorHandling' => [
                 [
                     'errorCode' => 404,
                     'errorHandler' => 'PHP',
-                    'errorPhpClassFQCN' => BackendUtility::class
+                    'errorPhpClassFQCN' => Random::class
                 ],
             ]
         ]);
index b8aa963..fa2aee3 100644 (file)
                "psr/http-server-middleware": "^1.0",
                "psr/log": "~1.0.0",
                "swiftmailer/swiftmailer": "~5.4.5",
-               "symfony/console": "^2.7 || ^3.0 || ^4.0",
-               "symfony/finder": "^2.7 || ^3.0 || ^4.0",
+               "symfony/console": "^4.1",
+               "symfony/finder": "^4.1",
                "symfony/polyfill-intl-icu": "^1.6",
                "symfony/polyfill-mbstring": "^1.2",
-               "symfony/yaml": "^2.7 || ^3.0 || ^4.0",
+               "symfony/routing": "^4.1",
+               "symfony/yaml": "^4.1",
                "typo3/class-alias-loader": "^1.0",
                "typo3/cms-cli": "^2.0",
                "typo3/cms-composer-installers": "^2.0",
index 89c77c2..206b315 100644 (file)
@@ -14,7 +14,7 @@
        },
        "require": {
                "typo3/cms-core": "9.4.*@dev",
-               "symfony/expression-language": "^3.4 || ^4.0"
+               "symfony/expression-language": "^4.1"
        },
        "conflict": {
                "typo3/cms": "*"
index 0fb79a4..37d8aa1 100644 (file)
@@ -19,6 +19,9 @@ 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\Database\ConnectionPool;
 use TYPO3\CMS\Core\Exception\SiteNotFoundException;
 use TYPO3\CMS\Core\Http\NormalizedParams;
@@ -39,6 +42,20 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
 class SiteResolver implements MiddlewareInterface
 {
     /**
+     * @var SiteFinder
+     */
+    protected $finder;
+
+    /**
+     * Injects necessary objects
+     * @param SiteFinder|null $finder
+     */
+    public function __construct(SiteFinder $finder = null)
+    {
+        $this->finder = $finder ?? GeneralUtility::makeInstance(SiteFinder::class);
+    }
+
+    /**
      * Resolve the site/language information by checking the page ID or the URL.
      *
      * @param ServerRequestInterface $request
@@ -47,8 +64,6 @@ class SiteResolver implements MiddlewareInterface
      */
     public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
     {
-        $finder = GeneralUtility::makeInstance(SiteFinder::class);
-
         $site = null;
         $language = null;
 
@@ -59,17 +74,35 @@ class SiteResolver implements MiddlewareInterface
         if ($pageId > 0 && $languageId !== null) {
             // Loop over the whole rootline without permissions to get the actual site information
             try {
-                $site = $finder->getSiteByPageId((int)$pageId);
+                $site = $this->finder->getSiteByPageId((int)$pageId);
                 $language = $site->getLanguageById((int)$languageId);
             } catch (SiteNotFoundException $e) {
+                // No site found by ID
             }
         }
+
+        // 2. Check if there is a site language, if not, do "Site Routing"
         if (!($language instanceof SiteLanguage)) {
-            // 2. Check if there is a site language, if not, just don't do anything
-            $language = $finder->getSiteLanguageByBase((string)$request->getUri());
-            // @todo: use exception for getSiteLanguageByBase
-            if ($language) {
-                $site = $language->getSite();
+            $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()
+            );
+            $matcher = new UrlMatcher($collection, $context);
+            try {
+                $result = $matcher->match($request->getUri()->getPath());
+                $site = $result['site'];
+                $language = $result['language'];
+            } catch (ResourceNotFoundException $e) {
+                // No site found
             }
         }
 
diff --git a/typo3/sysext/frontend/Tests/Unit/Middleware/SiteResolverTest.php b/typo3/sysext/frontend/Tests/Unit/Middleware/SiteResolverTest.php
new file mode 100644 (file)
index 0000000..5edbd89
--- /dev/null
@@ -0,0 +1,166 @@
+<?php
+declare(strict_types = 1);
+
+namespace TYPO3\CMS\Frontend\Tests\Unit\Middleware;
+
+/*
+ * 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\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+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\Site\Entity\Site;
+use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
+use TYPO3\CMS\Core\Site\SiteFinder;
+use TYPO3\CMS\Frontend\Middleware\SiteResolver;
+use TYPO3\TestingFramework\Core\AccessibleObjectInterface;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class SiteResolverTest extends UnitTestCase
+{
+    /**
+     * @var bool Reset singletons created by subject
+     */
+    protected $resetSingletonInstances = true;
+
+    /**
+     * @var SiteFinder|AccessibleObjectInterface
+     */
+    protected $siteFinder;
+
+    protected $siteFoundRequestHandler;
+
+    /**
+     * Set up
+     */
+    protected function setUp(): void
+    {
+        // Make global object available, however it is not actively used
+        $GLOBALS['TSFE'] = new \stdClass();
+        $this->siteFinder = $this->getAccessibleMock(SiteFinder::class, ['dummy'], [], '', false);
+
+        // A request handler which expects a site to be found.
+        $this->siteFoundRequestHandler = new class implements RequestHandlerInterface {
+            public function handle(ServerRequestInterface $request): ResponseInterface
+            {
+                /** @var Site $site */
+                /** @var SiteLanguage $language */
+                $site = $request->getAttribute('site', false);
+                $language = $request->getAttribute('language', false);
+                if ($site && $language) {
+                    return new JsonResponse(
+                        [
+                            'site' => $site->getIdentifier(),
+                            'language-id' => $language->getLanguageId(),
+                            'language-base' => $language->getBase()
+                        ]
+                    );
+                }
+                return new NullResponse();
+            }
+        };
+    }
+
+    /**
+     * Expect a URL handed in, as a request. This URL does not have a GET parameter "id"
+     * Then the site handling gets triggered, and the URL is taken to resolve a site.
+     *
+     * This case tests against a site with no domain or scheme, and successfully finds it.
+     *
+     * @test
+     */
+    public function detectASingleSiteWhenProperRequestIsGiven()
+    {
+        $incomingUrl = 'https://a-random-domain.com/mysite/';
+        $siteIdentifier = 'full-site';
+        $this->siteFinder->_set('sites', [
+            $siteIdentifier => new Site($siteIdentifier, 13, [
+                'base' => '/mysite/',
+                'languages' => [
+                    0 => [
+                        'languageId' => 0,
+                        'locale' => 'fr_FR.UTF-8',
+                        'base' => '/'
+                    ]
+                ]
+            ])
+        ]);
+
+        $request = new ServerRequest($incomingUrl, 'GET');
+        $subject = new SiteResolver($this->siteFinder);
+        $response = $subject->process($request, $this->siteFoundRequestHandler);
+        if ($response instanceof NullResponse) {
+            $this->fail('No site configuration found in URL ' . $incomingUrl . '.');
+        } else {
+            $result = $response->getBody()->getContents();
+            $result = json_decode($result, true);
+            $this->assertEquals($siteIdentifier, $result['site']);
+            $this->assertEquals(0, $result['language-id']);
+            $this->assertEquals('/mysite/', $result['language-base']);
+        }
+    }
+
+    /**
+     * Scenario with two sites
+     * Site 1: /
+     * Site 2: /mysubsite/
+     *
+     * The result should be that site 2 is resolved by the router when calling
+     *
+     * www.random-result.com/mysubsite/you-know-why/
+     *
+     * @test
+     */
+    public function detectSubsiteInsideNestedUrlStructure()
+    {
+        $incomingUrl = 'https://www.random-result.com/mysubsite/you-know-why/';
+        $this->siteFinder->_set('sites', [
+            'outside-site' => new Site('outside-site', 13, [
+                'base' => '/',
+                'languages' => [
+                    0 => [
+                        'languageId' => 0,
+                        'locale' => 'fr_FR.UTF-8',
+                        'base' => '/'
+                    ]
+                ]
+            ]),
+            'sub-site' => new Site('sub-site', 13, [
+                'base' => '/mysubsite/',
+                'languages' => [
+                    0 => [
+                        'languageId' => 0,
+                        'locale' => 'fr_FR.UTF-8',
+                        'base' => '/'
+                    ]
+                ]
+            ]),
+        ]);
+
+        $request = new ServerRequest($incomingUrl, 'GET');
+        $subject = new SiteResolver($this->siteFinder);
+        $response = $subject->process($request, $this->siteFoundRequestHandler);
+        if ($response instanceof NullResponse) {
+            $this->fail('No site configuration found in URL ' . $incomingUrl . '.');
+        } else {
+            $result = $response->getBody()->getContents();
+            $result = json_decode($result, true);
+            $this->assertEquals('sub-site', $result['site']);
+            $this->assertEquals(0, $result['language-id']);
+            $this->assertEquals('/mysubsite/', $result['language-base']);
+        }
+    }
+}
index f71e214..d40e5fb 100644 (file)
@@ -14,7 +14,7 @@
        },
        "require": {
                "nikic/php-parser": "^4.0",
-               "symfony/finder": "^2.7 || ^3.0 || ^4.0",
+               "symfony/finder": "^4.1",
                "typo3/cms-core": "9.4.*@dev",
                "typo3/cms-extbase": "9.4.*@dev",
                "typo3/cms-fluid": "9.4.*@dev"