src/Storefront/Framework/Routing/RequestTransformer.php line 318

  1. <?php declare(strict_types=1);
  2. namespace Shopware\Storefront\Framework\Routing;
  3. use Shopware\Core\Content\Seo\AbstractSeoResolver;
  4. use Shopware\Core\Framework\Log\Package;
  5. use Shopware\Core\Framework\Routing\RequestTransformerInterface;
  6. use Shopware\Core\PlatformRequest;
  7. use Shopware\Core\SalesChannelRequest;
  8. use Shopware\Storefront\Framework\Routing\Exception\SalesChannelMappingException;
  9. use Symfony\Component\HttpFoundation\Request;
  10. /**
  11.  * @phpstan-import-type Domain from AbstractDomainLoader
  12.  * @phpstan-import-type ResolvedSeoUrl from AbstractSeoResolver
  13.  */
  14. #[Package('storefront')]
  15. class RequestTransformer implements RequestTransformerInterface
  16. {
  17.     final public const REQUEST_TRANSFORMER_CACHE_KEY CachedDomainLoader::CACHE_KEY;
  18.     /**
  19.      * Virtual path of the "domain"
  20.      *
  21.      * @example
  22.      * - `/de`
  23.      * - `/en`
  24.      * - {empty} - the virtual path is optional
  25.      */
  26.     final public const SALES_CHANNEL_BASE_URL 'sw-sales-channel-base-url';
  27.     /**
  28.      * Scheme + Host + port + subdir in web root
  29.      *
  30.      * @example
  31.      * - `https://shop.example` - no subdir
  32.      * - `http://localhost:8000/subdir` - with sub dir `/subdir`
  33.      */
  34.     final public const SALES_CHANNEL_ABSOLUTE_BASE_URL 'sw-sales-channel-absolute-base-url';
  35.     /**
  36.      * Scheme + Host + port + subdir in web root + virtual path
  37.      *
  38.      * @example
  39.      * - `https://shop.example` - no sub dir and no virtual path
  40.      * - `https://shop.example/en` - no sub dir and virtual path `/en`
  41.      * - `http://localhost:8000/subdir` - with sub directory `/subdir`
  42.      * - `http://localhost:8000/subdir/de` - with sub directory `/subdir` and virtual path `/de`
  43.      */
  44.     final public const STOREFRONT_URL 'sw-storefront-url';
  45.     final public const SALES_CHANNEL_RESOLVED_URI 'resolved-uri';
  46.     final public const ORIGINAL_REQUEST_URI 'sw-original-request-uri';
  47.     private const INHERITABLE_ATTRIBUTE_NAMES = [
  48.         self::SALES_CHANNEL_BASE_URL,
  49.         self::SALES_CHANNEL_ABSOLUTE_BASE_URL,
  50.         self::STOREFRONT_URL,
  51.         self::SALES_CHANNEL_RESOLVED_URI,
  52.         PlatformRequest::ATTRIBUTE_SALES_CHANNEL_ID,
  53.         SalesChannelRequest::ATTRIBUTE_IS_SALES_CHANNEL_REQUEST,
  54.         SalesChannelRequest::ATTRIBUTE_DOMAIN_LOCALE,
  55.         SalesChannelRequest::ATTRIBUTE_DOMAIN_SNIPPET_SET_ID,
  56.         SalesChannelRequest::ATTRIBUTE_DOMAIN_CURRENCY_ID,
  57.         SalesChannelRequest::ATTRIBUTE_DOMAIN_ID,
  58.         SalesChannelRequest::ATTRIBUTE_THEME_ID,
  59.         SalesChannelRequest::ATTRIBUTE_THEME_NAME,
  60.         SalesChannelRequest::ATTRIBUTE_THEME_BASE_NAME,
  61.         SalesChannelRequest::ATTRIBUTE_CANONICAL_LINK,
  62.     ];
  63.     /**
  64.      * @var array<string>
  65.      */
  66.     private array $whitelist = [
  67.         '/_wdt/',
  68.         '/_profiler/',
  69.         '/_error/',
  70.         '/payment/finalize-transaction',
  71.         '/installer',
  72.     ];
  73.     /**
  74.      * @internal
  75.      *
  76.      * @param list<string> $registeredApiPrefixes
  77.      */
  78.     public function __construct(private readonly RequestTransformerInterface $decorated, private readonly AbstractSeoResolver $resolver, private readonly array $registeredApiPrefixes, private readonly AbstractDomainLoader $domainLoader)
  79.     {
  80.     }
  81.     public function transform(Request $request): Request
  82.     {
  83.         $request $this->decorated->transform($request);
  84.         if (!$this->isSalesChannelRequired($request->getPathInfo())) {
  85.             return $this->decorated->transform($request);
  86.         }
  87.         $salesChannel $this->findSalesChannel($request);
  88.         if ($salesChannel === null) {
  89.             // this class and therefore the "isSalesChannelRequired" method is currently not extendable
  90.             // which can cause problems when adding custom paths
  91.             throw new SalesChannelMappingException($request->getUri());
  92.         }
  93.         $absoluteBaseUrl $this->getSchemeAndHttpHost($request) . $request->getBaseUrl();
  94.         $baseUrl str_replace($absoluteBaseUrl''$salesChannel['url']);
  95.         $resolved $this->resolveSeoUrl(
  96.             $request,
  97.             $baseUrl,
  98.             $salesChannel['languageId'],
  99.             $salesChannel['salesChannelId']
  100.         );
  101.         $currentRequestUri $request->getRequestUri();
  102.         /**
  103.          * - Remove "virtual" suffix of domain mapping shopware.de/de
  104.          * - To get only the host shopware.de as real request uri shopware.de/
  105.          * - Resolve remaining seo url and get the real path info shopware.de/outdoor => shopware.de/navigation/{id}
  106.          *
  107.          * Possible domains
  108.          *
  109.          * same host, different "virtual" suffix
  110.          * http://shopware.de/de
  111.          * http://shopware.de/en
  112.          * http://shopware.de/fr
  113.          *
  114.          * same host, different location
  115.          * http://shopware.fr
  116.          * http://shopware.com
  117.          * http://shopware.de
  118.          *
  119.          * complete different host and location
  120.          * http://color.com
  121.          * http://farben.de
  122.          * http://couleurs.fr
  123.          *
  124.          * installation in sub directory
  125.          * http://localhost/development/public/de
  126.          * http://localhost/development/public/en
  127.          * http://localhost/development/public/fr
  128.          *
  129.          * installation with port
  130.          * http://localhost:8080
  131.          * http://localhost:8080/en
  132.          * http://localhost:8080/fr
  133.          */
  134.         $transformedServerVars array_merge(
  135.             $request->server->all(),
  136.             ['REQUEST_URI' => rtrim($request->getBaseUrl(), '/') . $resolved['pathInfo']]
  137.         );
  138.         $transformedRequest $request->duplicate(nullnullnullnullnull$transformedServerVars);
  139.         $transformedRequest->attributes->set(self::SALES_CHANNEL_BASE_URL$baseUrl);
  140.         $transformedRequest->attributes->set(self::SALES_CHANNEL_ABSOLUTE_BASE_URLrtrim($absoluteBaseUrl'/'));
  141.         $transformedRequest->attributes->set(
  142.             self::STOREFRONT_URL,
  143.             $transformedRequest->attributes->get(self::SALES_CHANNEL_ABSOLUTE_BASE_URL)
  144.             . $transformedRequest->attributes->get(self::SALES_CHANNEL_BASE_URL)
  145.         );
  146.         $transformedRequest->attributes->set(self::SALES_CHANNEL_RESOLVED_URI$resolved['pathInfo']);
  147.         $transformedRequest->attributes->set(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_ID$salesChannel['salesChannelId']);
  148.         $transformedRequest->attributes->set(SalesChannelRequest::ATTRIBUTE_IS_SALES_CHANNEL_REQUESTtrue);
  149.         $transformedRequest->attributes->set(SalesChannelRequest::ATTRIBUTE_DOMAIN_LOCALE$salesChannel['locale']);
  150.         $transformedRequest->attributes->set(SalesChannelRequest::ATTRIBUTE_DOMAIN_SNIPPET_SET_ID$salesChannel['snippetSetId']);
  151.         $transformedRequest->attributes->set(SalesChannelRequest::ATTRIBUTE_DOMAIN_CURRENCY_ID$salesChannel['currencyId']);
  152.         $transformedRequest->attributes->set(SalesChannelRequest::ATTRIBUTE_DOMAIN_ID$salesChannel['id']);
  153.         $transformedRequest->attributes->set(SalesChannelRequest::ATTRIBUTE_THEME_ID$salesChannel['themeId']);
  154.         $transformedRequest->attributes->set(SalesChannelRequest::ATTRIBUTE_THEME_NAME$salesChannel['themeName']);
  155.         $transformedRequest->attributes->set(SalesChannelRequest::ATTRIBUTE_THEME_BASE_NAME$salesChannel['parentThemeName']);
  156.         $transformedRequest->attributes->set(
  157.             SalesChannelRequest::ATTRIBUTE_SALES_CHANNEL_MAINTENANCE,
  158.             (bool) $salesChannel['maintenance']
  159.         );
  160.         $transformedRequest->attributes->set(
  161.             SalesChannelRequest::ATTRIBUTE_SALES_CHANNEL_MAINTENANCE_IP_WHITLELIST,
  162.             $salesChannel['maintenanceIpWhitelist']
  163.         );
  164.         if (isset($resolved['canonicalPathInfo'])) {
  165.             $urlPath parse_url($salesChannel['url'], \PHP_URL_PATH);
  166.             if ($urlPath === false || $urlPath === null) {
  167.                 $urlPath '';
  168.             }
  169.             $baseUrlPath trim($urlPath'/');
  170.             if (\strlen($baseUrlPath) > && !str_starts_with($baseUrlPath'/')) {
  171.                 $baseUrlPath '/' $baseUrlPath;
  172.             }
  173.             $transformedRequest->attributes->set(
  174.                 SalesChannelRequest::ATTRIBUTE_CANONICAL_LINK,
  175.                 $this->getSchemeAndHttpHost($request) . $baseUrlPath $resolved['canonicalPathInfo']
  176.             );
  177.         }
  178.         $transformedRequest->headers->add($request->headers->all());
  179.         $transformedRequest->headers->set(PlatformRequest::HEADER_LANGUAGE_ID$salesChannel['languageId']);
  180.         $transformedRequest->attributes->set(self::ORIGINAL_REQUEST_URI$currentRequestUri);
  181.         return $transformedRequest;
  182.     }
  183.     /**
  184.      * @return array<string, mixed>
  185.      */
  186.     public function extractInheritableAttributes(Request $sourceRequest): array
  187.     {
  188.         $inheritableAttributes $this->decorated
  189.             ->extractInheritableAttributes($sourceRequest);
  190.         foreach (self::INHERITABLE_ATTRIBUTE_NAMES as $attributeName) {
  191.             if (!$sourceRequest->attributes->has($attributeName)) {
  192.                 continue;
  193.             }
  194.             $inheritableAttributes[$attributeName] = $sourceRequest->attributes->get($attributeName);
  195.         }
  196.         return $inheritableAttributes;
  197.     }
  198.     private function isSalesChannelRequired(string $pathInfo): bool
  199.     {
  200.         $pathInfo rtrim($pathInfo'/') . '/';
  201.         foreach ($this->registeredApiPrefixes as $apiPrefix) {
  202.             if (mb_strpos($pathInfo'/' $apiPrefix '/') === 0) {
  203.                 return false;
  204.             }
  205.         }
  206.         foreach ($this->whitelist as $prefix) {
  207.             if (mb_strpos($pathInfo$prefix) === 0) {
  208.                 return false;
  209.             }
  210.         }
  211.         return true;
  212.     }
  213.     /**
  214.      * @return Domain|null
  215.      */
  216.     private function findSalesChannel(Request $request): ?array
  217.     {
  218.         $domains $this->domainLoader->load();
  219.         if (empty($domains)) {
  220.             return null;
  221.         }
  222.         // domain urls and request uri should be in same format, all with trailing slash
  223.         $requestUrl rtrim($this->getSchemeAndHttpHost($request) . $request->getBasePath() . $request->getPathInfo(), '/') . '/';
  224.         // direct hit
  225.         if (\array_key_exists($requestUrl$domains)) {
  226.             $domain $domains[$requestUrl];
  227.             $domain['url'] = rtrim($domain['url'], '/');
  228.             return $domain;
  229.         }
  230.         // reduce shops to which base url is the beginning of the request
  231.         $domains array_filter($domains, fn ($baseUrl) => mb_strpos($requestUrl, (string) $baseUrl) === 0\ARRAY_FILTER_USE_KEY);
  232.         if (empty($domains)) {
  233.             return null;
  234.         }
  235.         // determine most matching shop base url
  236.         $lastBaseUrl '';
  237.         $bestMatch current($domains);
  238.         /** @var string $baseUrl */
  239.         foreach ($domains as $baseUrl => $urlConfig) {
  240.             if (mb_strlen($baseUrl) > mb_strlen($lastBaseUrl)) {
  241.                 $bestMatch $urlConfig;
  242.             }
  243.             $lastBaseUrl $baseUrl;
  244.         }
  245.         $bestMatch['url'] = rtrim($bestMatch['url'], '/');
  246.         return $bestMatch;
  247.     }
  248.     /**
  249.      * @return ResolvedSeoUrl
  250.      */
  251.     private function resolveSeoUrl(Request $requeststring $baseUrlstring $languageIdstring $salesChannelId): array
  252.     {
  253.         $seoPathInfo $request->getPathInfo();
  254.         // only remove full base url not part
  255.         // registered domain: 'shop-dev.de/de'
  256.         // incoming request:  'shop-dev.de/detail'
  257.         // without leading slash, detail would be stripped
  258.         $baseUrl rtrim($baseUrl'/') . '/';
  259.         if ($this->equalsBaseUrl($seoPathInfo$baseUrl)) {
  260.             $seoPathInfo '';
  261.         } elseif ($this->containsBaseUrl($seoPathInfo$baseUrl)) {
  262.             $seoPathInfo mb_substr($seoPathInfomb_strlen($baseUrl));
  263.         }
  264.         $resolved $this->resolver->resolve($languageId$salesChannelId$seoPathInfo);
  265.         $resolved['pathInfo'] = '/' ltrim($resolved['pathInfo'], '/');
  266.         return $resolved;
  267.     }
  268.     private function getSchemeAndHttpHost(Request $request): string
  269.     {
  270.         return $request->getScheme() . '://' idn_to_utf8($request->getHttpHost());
  271.     }
  272.     /**
  273.      * We add the trailing slash to the base url
  274.      * so we have to add it to the path info too, to check if they are equal
  275.      */
  276.     private function equalsBaseUrl(string $seoPathInfostring $baseUrl): bool
  277.     {
  278.         return $baseUrl === rtrim($seoPathInfo'/') . '/';
  279.     }
  280.     /**
  281.      * We don't have to add the trailing slash when we check if the pathInfo contains teh base url
  282.      */
  283.     private function containsBaseUrl(string $seoPathInfostring $baseUrl): bool
  284.     {
  285.         return !empty($baseUrl) && mb_strpos($seoPathInfo$baseUrl) === 0;
  286.     }
  287. }