[BUGFIX] Redirect site to language after site resolving 14/58614/9
authorGeorg Ringer <georg.ringer@gmail.com>
Thu, 11 Oct 2018 10:01:59 +0000 (12:01 +0200)
committerBenni Mack <benni@typo3.org>
Sun, 28 Oct 2018 16:14:24 +0000 (17:14 +0100)
In order to allow custom redirects not depending on the site language
base, the redirect functionality is extracted into a separte middleware.

This allows to also exchange the base redirects with
a custom middleware when GeoIP / UserAgent based language
detection is necessary.

Resolves: #86615
Releases: master
Change-Id: I93e3452dfb55aa2d45b4c6d464944bf5a5d7fbe9
Reviewed-on: https://review.typo3.org/58614
Reviewed-by: Susanne Moog <susanne.moog@typo3.org>
Reviewed-by: Daniel Goerz <daniel.goerz@posteo.de>
Tested-by: Daniel Goerz <daniel.goerz@posteo.de>
Tested-by: TYPO3com <no-reply@typo3.com>
Tested-by: Susanne Moog <susanne.moog@typo3.org>
Reviewed-by: Benni Mack <benni@typo3.org>
Tested-by: Benni Mack <benni@typo3.org>
typo3/sysext/frontend/Classes/Middleware/SiteBaseRedirectResolver.php [new file with mode: 0644]
typo3/sysext/frontend/Classes/Middleware/SiteResolver.php
typo3/sysext/frontend/Configuration/RequestMiddlewares.php
typo3/sysext/frontend/Tests/Unit/Middleware/SiteBaseRedirectResolverTest.php [new file with mode: 0644]
typo3/sysext/frontend/Tests/Unit/Middleware/SiteResolverTest.php

diff --git a/typo3/sysext/frontend/Classes/Middleware/SiteBaseRedirectResolver.php b/typo3/sysext/frontend/Classes/Middleware/SiteBaseRedirectResolver.php
new file mode 100644 (file)
index 0000000..19dd8ed
--- /dev/null
@@ -0,0 +1,97 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Frontend\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\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
+use TYPO3\CMS\Core\Http\RedirectResponse;
+use TYPO3\CMS\Core\Routing\SiteRouteResult;
+use TYPO3\CMS\Core\Site\Entity\Site;
+use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Frontend\Controller\ErrorController;
+use TYPO3\CMS\Frontend\Page\PageAccessFailureReasons;
+
+/**
+ * Resolves redirects of site if base is not /
+ * Can be replaced or extended by extensions if GeoIP-based or user-agent based language redirects need to happen.
+ */
+class SiteBaseRedirectResolver implements MiddlewareInterface
+{
+    /**
+     * Redirect to default language if required
+     *
+     * @param ServerRequestInterface $request
+     * @param RequestHandlerInterface $handler
+     * @return ResponseInterface
+     */
+    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+    {
+        $site = $request->getAttribute('site', null);
+        $language = $request->getAttribute('language', null);
+        $routeResult = $request->getAttribute('routing', null);
+
+        // Usually called when "https://www.example.com" was entered, but all sites have "https://www.example.com/lang-key/"
+        // So a redirect to the first possible language is done.
+        if ($site instanceof Site && !($language instanceof SiteLanguage)) {
+            $language = $site->getDefaultLanguage();
+            return new RedirectResponse($language->getBase(), 307);
+        }
+
+        // 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)) {
+            return GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
+                $request,
+                'Page is not available in the requested language.',
+                ['code' => PageAccessFailureReasons::LANGUAGE_NOT_AVAILABLE]
+            );
+        }
+
+        if ($language instanceof SiteLanguage && $routeResult instanceof SiteRouteResult) {
+            $requestedUri = $request->getUri();
+            $tail = $routeResult->getTail();
+            // a URL was called via "/fr-FR/" but the page is actually called "/fr-FR", let's do a redirect
+            if ($tail === '/') {
+                $uri = $requestedUri->withPath(rtrim($requestedUri->getPath(), '/'));
+                return new RedirectResponse($uri, 307);
+            }
+            // Request was "/fr-FR" but the site is actually called "/fr-FR/", let's do a redirect
+            if ($tail === '' && $language->getBase()->getPath() !== $requestedUri->getPath()) {
+                $uri = $requestedUri->withPath($requestedUri->getPath() . '/');
+                return new RedirectResponse($uri, 307);
+            }
+        }
+        return $handler->handle($request);
+    }
+    /**
+     * Checks if the language is allowed in Frontend, if not, check if there is valid BE user
+     *
+     * @param SiteLanguage|null $language
+     * @param BackendUserAuthentication|null $user
+     * @return bool
+     */
+    protected function isLanguageEnabled(SiteLanguage $language, BackendUserAuthentication $user = null): bool
+    {
+        // language is hidden, check if a possible backend user is allowed to access the language
+        if ($language->enabled() || ($user instanceof BackendUserAuthentication && $user->checkLanguageAccess($language->getLanguageId()))) {
+            return true;
+        }
+        return false;
+    }
+}
index 85b23c6..7f5d60f 100644 (file)
@@ -19,15 +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\Core\Authentication\BackendUserAuthentication;
-use TYPO3\CMS\Core\Http\RedirectResponse;
 use TYPO3\CMS\Core\Routing\SiteMatcher;
-use TYPO3\CMS\Core\Site\Entity\Site;
-use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
+use TYPO3\CMS\Core\Routing\SiteRouteResult;
 use TYPO3\CMS\Core\Site\SiteFinder;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
-use TYPO3\CMS\Frontend\Controller\ErrorController;
-use TYPO3\CMS\Frontend\Page\PageAccessFailureReasons;
 
 /**
  * Identifies if a site is configured for the request, based on "id" and "L" GET/POST parameters, or the requested
@@ -35,8 +30,6 @@ use TYPO3\CMS\Frontend\Page\PageAccessFailureReasons;
  *
  * If a site is found, the request is populated with the found language+site objects. If none is found, the main magic
  * is handled by the PageResolver middleware.
- *
- * In addition to that, TSFE gets the $domainStartPage information resolved and added.
  */
 class SiteResolver implements MiddlewareInterface
 {
@@ -62,63 +55,16 @@ class SiteResolver implements MiddlewareInterface
      */
     public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
     {
+        /** @var SiteRouteResult $routeResult */
         $routeResult = $this->matcher->matchRequest($request);
-        $site = $routeResult->getSite();
-        $language = $routeResult->getLanguage();
-
-        $request = $request->withAttribute('site', $site);
+        $request = $request->withAttribute('site', $routeResult->getSite());
+        $request = $request->withAttribute('language', $routeResult->getLanguage());
         $request = $request->withAttribute('routing', $routeResult);
 
-        // Usually called when "https://www.example.com" was entered, but all sites have "https://www.example.com/lang-key/"
-        // So a redirect to the first possible language is done.
-        if ($site instanceof Site && !($language instanceof SiteLanguage)) {
-            $language = $site->getDefaultLanguage();
-            $uri = $language->getBase();
-            return new RedirectResponse($uri, 307);
-        }
-        // language is found, and hidden but also not visible to the BE user, this needs to fail
-        if ($language instanceof SiteLanguage) {
-            if (!$this->isLanguageEnabled($language, $GLOBALS['BE_USER'] ?? null)) {
-                return GeneralUtility::makeInstance(ErrorController::class)->pageNotFoundAction(
-                    $request,
-                    'Page is not available in the requested language.',
-                    ['code' => PageAccessFailureReasons::LANGUAGE_NOT_AVAILABLE]
-                );
-            }
-            $requestedUri = $request->getUri();
-            $tail = $routeResult->getTail();
-            // a URL was called via "/fr-FR/" but the page is actually called "/fr-FR", let's do a redirect
-            if ($tail === '/') {
-                $uri = $requestedUri->withPath(rtrim($requestedUri->getPath(), '/'));
-                return new RedirectResponse($uri, 307);
-            }
-            // Request was "/fr-FR" but the site is actually called "/fr-FR/", let's do a redirect
-            if ($tail === '' && $language->getBase()->getPath() !== $requestedUri->getPath()) {
-                $uri = $requestedUri->withPath($requestedUri->getPath() . '/');
-                return new RedirectResponse($uri, 307);
-            }
-            $request = $request->withAttribute('language', $language);
-        }
-
         // 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;
 
         return $handler->handle($request);
     }
-    /**
-     * Checks if the language is allowed in Frontend, if not, check if there is valid BE user
-     *
-     * @param SiteLanguage|null $language
-     * @param BackendUserAuthentication|null $user
-     * @return bool
-     */
-    protected function isLanguageEnabled(SiteLanguage $language, BackendUserAuthentication $user = null): bool
-    {
-        // language is hidden, check if a possible backend user is allowed to access the language
-        if ($language->enabled() || ($user instanceof BackendUserAuthentication && $user->checkLanguageAccess($language->getLanguageId()))) {
-            return true;
-        }
-        return false;
-    }
 }
index 68c9931..b0f6a66 100644 (file)
@@ -88,10 +88,19 @@ return [
                 'typo3/cms-frontend/page-resolver'
             ]
         ],
+        'typo3/cms-frontend/base-redirect-resolver' => [
+            'target' => \TYPO3\CMS\Frontend\Middleware\SiteBaseRedirectResolver::class,
+            'after' => [
+                'typo3/cms-frontend/site-resolver',
+            ],
+            'before' => [
+                'typo3/cms-frontend/static-route-resolver'
+            ]
+        ],
         'typo3/cms-frontend/static-route-resolver' => [
             'target' => \TYPO3\CMS\Frontend\Middleware\StaticRouteResolver::class,
             'after' => [
-                'typo3/cms-frontend/site',
+                'typo3/cms-frontend/base-redirect-resolver',
             ],
             'before' => [
                 'typo3/cms-frontend/page-resolver'
diff --git a/typo3/sysext/frontend/Tests/Unit/Middleware/SiteBaseRedirectResolverTest.php b/typo3/sysext/frontend/Tests/Unit/Middleware/SiteBaseRedirectResolverTest.php
new file mode 100644 (file)
index 0000000..52322ad
--- /dev/null
@@ -0,0 +1,221 @@
+<?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\Http\Uri;
+use TYPO3\CMS\Core\Routing\SiteRouteResult;
+use TYPO3\CMS\Core\Site\Entity\Site;
+use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
+use TYPO3\CMS\Frontend\Middleware\SiteBaseRedirectResolver;
+use TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\Fixtures\PhpError;
+use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
+
+class SiteBaseRedirectResolverTest extends UnitTestCase
+{
+    /**
+     * @var bool Reset singletons created by subject
+     */
+    protected $resetSingletonInstances = true;
+
+    /**
+     * @var RequestHandlerInterface
+     */
+    protected $siteFoundRequestHandler;
+
+    /**
+     * Set up
+     */
+    protected function setUp(): void
+    {
+        // 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' => (string)$language->getBase(),
+                            'rootpage' => $site->getRootPageId()
+                        ]
+                    );
+                }
+                return new NullResponse();
+            }
+        };
+    }
+
+    /**
+     * @return array
+     */
+    public function doRedirectOnMissingOrSuperfluousRequestUrlDataProvider(): array
+    {
+        $site1 = new Site('outside-site', 13, [
+            'base' => 'https://twenty.one/',
+            'languages' => [
+                0 => [
+                    'languageId' => 0,
+                    'locale' => 'en_US.UTF-8',
+                    'base' => '/en/'
+                ],
+                1 => [
+                    'languageId' => 1,
+                    'locale' => 'fr_CA.UTF-8',
+                    'base' => '/fr'
+                ]
+            ]
+        ]);
+        $site2 = new Site('sub-site', 14, [
+            'base' => 'https://twenty.one/mysubsite/',
+            'languages' => [
+                2 => [
+                    'languageId' => 2,
+                    'locale' => 'it_IT.UTF-8',
+                    'base' => '/'
+                ]
+            ]
+        ]);
+
+        return [
+            'redirect to first language' => [
+                'https://twenty.one/',
+                'https://twenty.one/en/',
+                $site1,
+                null,
+                ''
+            ],
+            'redirect to first language adding a slash' => [
+                'https://twenty.one/en',
+                'https://twenty.one/en/',
+                $site1,
+                $site1->getLanguageById(0),
+                ''
+            ],
+            'redirect to second language removing a slash' => [
+                'https://twenty.one/fr/',
+                'https://twenty.one/fr',
+                $site1,
+                $site1->getLanguageById(1),
+                '/'
+            ],
+            'redirect to subsite by adding a slash' => [
+                'https://twenty.one/mysubsite',
+                'https://twenty.one/mysubsite/',
+                $site2,
+                null,
+                ''
+            ],
+        ];
+    }
+
+    /**
+     * @param string $incomingUrl
+     * @param string $expectedRedirectUrl
+     * @param Site $site
+     * @param SiteLanguage|null $language
+     * @param string $tail
+     * @dataProvider doRedirectOnMissingOrSuperfluousRequestUrlDataProvider
+     * @test
+     */
+    public function doRedirectOnMissingOrSuperfluousRequestUrl(
+        string $incomingUrl,
+        string $expectedRedirectUrl,
+        Site $site,
+        ?SiteLanguage $language,
+        string $tail
+    ) {
+        $routeResult = new SiteRouteResult(new Uri($incomingUrl), $site, $language, $tail);
+        $request = new ServerRequest($incomingUrl, 'GET');
+        $request = $request->withAttribute('site', $site);
+        $request = $request->withAttribute('language', $language);
+        $request = $request->withAttribute('routing', $routeResult);
+
+        $subject = new SiteBaseRedirectResolver();
+        $response = $subject->process($request, $this->siteFoundRequestHandler);
+        $this->assertEquals(307, $response->getStatusCode());
+        $this->assertEquals($expectedRedirectUrl, $response->getHeader('Location')[0] ?? '');
+    }
+
+    /**
+     * @return array
+     */
+    public function checkIf404IsSiteLanguageIsDisabledInFrontendDataProvider(): array
+    {
+        return [
+            'disabled site language' => ['https://twenty.one/en/pilots/', 404, 0],
+            'enabled site language' => ['https://twenty.one/fr/pilots/', 200, 1],
+        ];
+    }
+
+    /**
+     * @param string $url
+     * @param int $expectedStatusCode
+     * @param int $languageId
+     *
+     * @test
+     * @dataProvider checkIf404IsSiteLanguageIsDisabledInFrontendDataProvider
+     */
+    public function checkIf404IsSiteLanguageIsDisabledInFrontend(
+        string $url,
+        int $expectedStatusCode,
+        int $languageId
+    ) {
+        $site = new Site('mixed-site', 13, [
+            'base' => '/',
+            'errorHandling' => [
+                [
+                    'errorCode' => 404,
+                    'errorHandler' => 'PHP',
+                    'errorPhpClassFQCN' => PhpError::class
+                ]
+            ],
+            'languages' => [
+                0 => [
+                    'languageId' => 0,
+                    'locale' => 'en_US.UTF-8',
+                    'base' => '/en/',
+                    'enabled' => false
+                ],
+                1 => [
+                    'languageId' => 1,
+                    'locale' => 'fr_CA.UTF-8',
+                    'base' => '/fr/',
+                    'enabled' => true
+                ]
+            ]
+        ]);
+
+        // Request to default page
+        $request = new ServerRequest($url, 'GET');
+        $request = $request->withAttribute('site', $site);
+        $request = $request->withAttribute('language', $site->getLanguageById($languageId));
+        $subject = new SiteBaseRedirectResolver();
+        $response = $subject->process($request, $this->siteFoundRequestHandler);
+        $this->assertEquals($expectedStatusCode, $response->getStatusCode());
+    }
+}
index 8641346..ad44c35 100644 (file)
@@ -29,7 +29,6 @@ use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
 use TYPO3\CMS\Core\Site\SiteFinder;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Frontend\Middleware\SiteResolver;
-use TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\Fixtures\PhpError;
 use TYPO3\TestingFramework\Core\AccessibleObjectInterface;
 use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
 
@@ -377,127 +376,4 @@ class SiteResolverTest extends UnitTestCase
             $this->assertEquals($expectedBase, $result['language-base']);
         }
     }
-
-    public function doRedirectOnMissingOrSuperfluousRequestUrlDataProvider()
-    {
-        return [
-            'redirect to first language' => [
-                'https://twenty.one/',
-                'https://twenty.one/en/',
-            ],
-            'redirect to first language adding a slash' => [
-                'https://twenty.one/en',
-                'https://twenty.one/en/',
-            ],
-            'redirect to second language removing a slash' => [
-                'https://twenty.one/fr/',
-                'https://twenty.one/fr',
-            ],
-            'redirect to subsite by adding a slash' => [
-                'https://twenty.one/mysubsite',
-                'https://twenty.one/mysubsite/',
-            ],
-        ];
-    }
-
-    /**
-     * @param string $incomingUrl
-     * @param string $expectedRedirectUrl
-     * @dataProvider doRedirectOnMissingOrSuperfluousRequestUrlDataProvider
-     * @test
-     */
-    public function doRedirectOnMissingOrSuperfluousRequestUrl(string $incomingUrl, string $expectedRedirectUrl)
-    {
-        $this->siteFinder->_set('sites', [
-            'outside-site' => new Site('outside-site', 13, [
-                'base' => 'https://twenty.one/',
-                'languages' => [
-                    0 => [
-                        'languageId' => 0,
-                        'locale' => 'en_US.UTF-8',
-                        'base' => '/en/'
-                    ],
-                    1 => [
-                        'languageId' => 1,
-                        'locale' => 'fr_CA.UTF-8',
-                        'base' => '/fr'
-                    ]
-                ]
-            ]),
-            'sub-site' => new Site('sub-site', 14, [
-                'base' => 'https://twenty.one/mysubsite/',
-                'languages' => [
-                    2 => [
-                        'languageId' => 2,
-                        'locale' => 'it_IT.UTF-8',
-                        'base' => '/'
-                    ]
-                ]
-            ]),
-        ]);
-
-        $subject = new SiteResolver(new SiteMatcher($this->siteFinder));
-
-        $request = new ServerRequest($incomingUrl, 'GET');
-        $response = $subject->process($request, $this->siteFoundRequestHandler);
-        $this->assertEquals(307, $response->getStatusCode());
-        $this->assertEquals($expectedRedirectUrl, $response->getHeader('Location')[0] ?? '');
-    }
-
-    /**
-     * @return array
-     */
-    public function checkIf404IsSiteLanguageIsDisabledInFrontendDataProvider(): array
-    {
-        return [
-            'disabled site language' => ['https://twenty.one/en/pilots/', 404],
-            'enabled site language' => ['https://twenty.one/fr/pilots/', 200],
-        ];
-    }
-
-    /**
-     * @param string $url
-     * @param int $expectedStatusCode
-     *
-     * @test
-     * @dataProvider checkIf404IsSiteLanguageIsDisabledInFrontendDataProvider
-     */
-    public function checkIf404IsSiteLanguageIsDisabledInFrontend(
-        string $url,
-        int $expectedStatusCode
-    ) {
-        $this->siteFinder->_set('sites', [
-            'mixed-site' => new Site('mixed-site', 13, [
-                'base' => '/',
-                'errorHandling' => [
-                    [
-                        'errorCode' => 404,
-                        'errorHandler' => 'PHP',
-                        'errorPhpClassFQCN' => PhpError::class
-                    ]
-                ],
-                'languages' => [
-                    0 => [
-                        'languageId' => 0,
-                        'locale' => 'en_US.UTF-8',
-                        'base' => '/en/',
-                        'enabled' => false
-                    ],
-                    1 => [
-                        'languageId' => 1,
-                        'locale' => 'fr_CA.UTF-8',
-                        'base' => '/fr/',
-                        'enabled' => true
-                    ]
-                ]
-            ]),
-        ]);
-
-        $subject = new SiteResolver(new SiteMatcher($this->siteFinder));
-
-        // Request to default page
-        $request = new ServerRequest($url, 'GET');
-        $response = $subject->process($request, $this->siteFoundRequestHandler);
-        static::assertEquals($expectedStatusCode, $response->getStatusCode());
-    }
 }