vendor/shopware/storefront/Framework/Routing/RequestTransformer.php line 322

  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(
  79.         private readonly RequestTransformerInterface $decorated,
  80.         private readonly AbstractSeoResolver $resolver,
  81.         private readonly array $registeredApiPrefixes,
  82.         private readonly AbstractDomainLoader $domainLoader
  83.     ) {
  84.     }
  85.     public function transform(Request $request): Request
  86.     {
  87.         $request $this->decorated->transform($request);
  88.         if (!$this->isSalesChannelRequired($request->getPathInfo())) {
  89.             return $this->decorated->transform($request);
  90.         }
  91.         $salesChannel $this->findSalesChannel($request);
  92.         if ($salesChannel === null) {
  93.             // this class and therefore the "isSalesChannelRequired" method is currently not extendable
  94.             // which can cause problems when adding custom paths
  95.             throw new SalesChannelMappingException($request->getUri());
  96.         }
  97.         $absoluteBaseUrl $this->getSchemeAndHttpHost($request) . $request->getBaseUrl();
  98.         $baseUrl str_replace($absoluteBaseUrl''$salesChannel['url']);
  99.         $resolved $this->resolveSeoUrl(
  100.             $request,
  101.             $baseUrl,
  102.             $salesChannel['languageId'],
  103.             $salesChannel['salesChannelId']
  104.         );
  105.         $currentRequestUri $request->getRequestUri();
  106.         /**
  107.          * - Remove "virtual" suffix of domain mapping shopware.de/de
  108.          * - To get only the host shopware.de as real request uri shopware.de/
  109.          * - Resolve remaining seo url and get the real path info shopware.de/outdoor => shopware.de/navigation/{id}
  110.          *
  111.          * Possible domains
  112.          *
  113.          * same host, different "virtual" suffix
  114.          * http://shopware.de/de
  115.          * http://shopware.de/en
  116.          * http://shopware.de/fr
  117.          *
  118.          * same host, different location
  119.          * http://shopware.fr
  120.          * http://shopware.com
  121.          * http://shopware.de
  122.          *
  123.          * complete different host and location
  124.          * http://color.com
  125.          * http://farben.de
  126.          * http://couleurs.fr
  127.          *
  128.          * installation in sub directory
  129.          * http://localhost/development/public/de
  130.          * http://localhost/development/public/en
  131.          * http://localhost/development/public/fr
  132.          *
  133.          * installation with port
  134.          * http://localhost:8080
  135.          * http://localhost:8080/en
  136.          * http://localhost:8080/fr
  137.          */
  138.         $transformedServerVars array_merge(
  139.             $request->server->all(),
  140.             ['REQUEST_URI' => rtrim($request->getBaseUrl(), '/') . $resolved['pathInfo']]
  141.         );
  142.         $transformedRequest $request->duplicate(nullnullnullnullnull$transformedServerVars);
  143.         $transformedRequest->attributes->set(self::SALES_CHANNEL_BASE_URL$baseUrl);
  144.         $transformedRequest->attributes->set(self::SALES_CHANNEL_ABSOLUTE_BASE_URLrtrim($absoluteBaseUrl'/'));
  145.         $transformedRequest->attributes->set(
  146.             self::STOREFRONT_URL,
  147.             $transformedRequest->attributes->get(self::SALES_CHANNEL_ABSOLUTE_BASE_URL)
  148.             . $transformedRequest->attributes->get(self::SALES_CHANNEL_BASE_URL)
  149.         );
  150.         $transformedRequest->attributes->set(self::SALES_CHANNEL_RESOLVED_URI$resolved['pathInfo']);
  151.         $transformedRequest->attributes->set(PlatformRequest::ATTRIBUTE_SALES_CHANNEL_ID$salesChannel['salesChannelId']);
  152.         $transformedRequest->attributes->set(SalesChannelRequest::ATTRIBUTE_IS_SALES_CHANNEL_REQUESTtrue);
  153.         $transformedRequest->attributes->set(SalesChannelRequest::ATTRIBUTE_DOMAIN_LOCALE$salesChannel['locale']);
  154.         $transformedRequest->attributes->set(SalesChannelRequest::ATTRIBUTE_DOMAIN_SNIPPET_SET_ID$salesChannel['snippetSetId']);
  155.         $transformedRequest->attributes->set(SalesChannelRequest::ATTRIBUTE_DOMAIN_CURRENCY_ID$salesChannel['currencyId']);
  156.         $transformedRequest->attributes->set(SalesChannelRequest::ATTRIBUTE_DOMAIN_ID$salesChannel['id']);
  157.         $transformedRequest->attributes->set(SalesChannelRequest::ATTRIBUTE_THEME_ID$salesChannel['themeId']);
  158.         $transformedRequest->attributes->set(SalesChannelRequest::ATTRIBUTE_THEME_NAME$salesChannel['themeName']);
  159.         $transformedRequest->attributes->set(SalesChannelRequest::ATTRIBUTE_THEME_BASE_NAME$salesChannel['parentThemeName']);
  160.         $transformedRequest->attributes->set(
  161.             SalesChannelRequest::ATTRIBUTE_SALES_CHANNEL_MAINTENANCE,
  162.             (bool) $salesChannel['maintenance']
  163.         );
  164.         $transformedRequest->attributes->set(
  165.             SalesChannelRequest::ATTRIBUTE_SALES_CHANNEL_MAINTENANCE_IP_WHITLELIST,
  166.             $salesChannel['maintenanceIpWhitelist']
  167.         );
  168.         if (isset($resolved['canonicalPathInfo'])) {
  169.             $urlPath parse_url($salesChannel['url'], \PHP_URL_PATH);
  170.             if ($urlPath === false || $urlPath === null) {
  171.                 $urlPath '';
  172.             }
  173.             $baseUrlPath trim($urlPath'/');
  174.             if (\strlen($baseUrlPath) > && !str_starts_with($baseUrlPath'/')) {
  175.                 $baseUrlPath '/' $baseUrlPath;
  176.             }
  177.             $transformedRequest->attributes->set(
  178.                 SalesChannelRequest::ATTRIBUTE_CANONICAL_LINK,
  179.                 $this->getSchemeAndHttpHost($request) . $baseUrlPath $resolved['canonicalPathInfo']
  180.             );
  181.         }
  182.         $transformedRequest->headers->add($request->headers->all());
  183.         $transformedRequest->headers->set(PlatformRequest::HEADER_LANGUAGE_ID$salesChannel['languageId']);
  184.         $transformedRequest->attributes->set(self::ORIGINAL_REQUEST_URI$currentRequestUri);
  185.         return $transformedRequest;
  186.     }
  187.     /**
  188.      * @return array<string, mixed>
  189.      */
  190.     public function extractInheritableAttributes(Request $sourceRequest): array
  191.     {
  192.         $inheritableAttributes $this->decorated
  193.             ->extractInheritableAttributes($sourceRequest);
  194.         foreach (self::INHERITABLE_ATTRIBUTE_NAMES as $attributeName) {
  195.             if (!$sourceRequest->attributes->has($attributeName)) {
  196.                 continue;
  197.             }
  198.             $inheritableAttributes[$attributeName] = $sourceRequest->attributes->get($attributeName);
  199.         }
  200.         return $inheritableAttributes;
  201.     }
  202.     private function isSalesChannelRequired(string $pathInfo): bool
  203.     {
  204.         $pathInfo rtrim($pathInfo'/') . '/';
  205.         foreach ($this->registeredApiPrefixes as $apiPrefix) {
  206.             if (mb_strpos($pathInfo'/' $apiPrefix '/') === 0) {
  207.                 return false;
  208.             }
  209.         }
  210.         foreach ($this->whitelist as $prefix) {
  211.             if (mb_strpos($pathInfo$prefix) === 0) {
  212.                 return false;
  213.             }
  214.         }
  215.         return true;
  216.     }
  217.     /**
  218.      * @return Domain|null
  219.      */
  220.     private function findSalesChannel(Request $request): ?array
  221.     {
  222.         $domains $this->domainLoader->load();
  223.         if (empty($domains)) {
  224.             return null;
  225.         }
  226.         // domain urls and request uri should be in same format, all with trailing slash
  227.         $requestUrl rtrim($this->getSchemeAndHttpHost($request) . $request->getBasePath() . $request->getPathInfo(), '/') . '/';
  228.         // direct hit
  229.         if (\array_key_exists($requestUrl$domains)) {
  230.             $domain $domains[$requestUrl];
  231.             $domain['url'] = rtrim($domain['url'], '/');
  232.             return $domain;
  233.         }
  234.         // reduce shops to which base url is the beginning of the request
  235.         $domains array_filter($domains, fn ($baseUrl) => mb_strpos($requestUrl, (string) $baseUrl) === 0\ARRAY_FILTER_USE_KEY);
  236.         if (empty($domains)) {
  237.             return null;
  238.         }
  239.         // determine most matching shop base url
  240.         $lastBaseUrl '';
  241.         $bestMatch current($domains);
  242.         /** @var string $baseUrl */
  243.         foreach ($domains as $baseUrl => $urlConfig) {
  244.             if (mb_strlen($baseUrl) > mb_strlen($lastBaseUrl)) {
  245.                 $bestMatch $urlConfig;
  246.             }
  247.             $lastBaseUrl $baseUrl;
  248.         }
  249.         $bestMatch['url'] = rtrim($bestMatch['url'], '/');
  250.         return $bestMatch;
  251.     }
  252.     /**
  253.      * @return ResolvedSeoUrl
  254.      */
  255.     private function resolveSeoUrl(Request $requeststring $baseUrlstring $languageIdstring $salesChannelId): array
  256.     {
  257.         $seoPathInfo $request->getPathInfo();
  258.         // only remove full base url not part
  259.         // registered domain: 'shop-dev.de/de'
  260.         // incoming request:  'shop-dev.de/detail'
  261.         // without leading slash, detail would be stripped
  262.         $baseUrl rtrim($baseUrl'/') . '/';
  263.         if ($this->equalsBaseUrl($seoPathInfo$baseUrl)) {
  264.             $seoPathInfo '';
  265.         } elseif ($this->containsBaseUrl($seoPathInfo$baseUrl)) {
  266.             $seoPathInfo mb_substr($seoPathInfomb_strlen($baseUrl));
  267.         }
  268.         $resolved $this->resolver->resolve($languageId$salesChannelId$seoPathInfo);
  269.         $resolved['pathInfo'] = '/' ltrim($resolved['pathInfo'], '/');
  270.         return $resolved;
  271.     }
  272.     private function getSchemeAndHttpHost(Request $request): string
  273.     {
  274.         return $request->getScheme() . '://' idn_to_utf8($request->getHttpHost());
  275.     }
  276.     /**
  277.      * We add the trailing slash to the base url
  278.      * so we have to add it to the path info too, to check if they are equal
  279.      */
  280.     private function equalsBaseUrl(string $seoPathInfostring $baseUrl): bool
  281.     {
  282.         return $baseUrl === rtrim($seoPathInfo'/') . '/';
  283.     }
  284.     /**
  285.      * We don't have to add the trailing slash when we check if the pathInfo contains teh base url
  286.      */
  287.     private function containsBaseUrl(string $seoPathInfostring $baseUrl): bool
  288.     {
  289.         return !empty($baseUrl) && mb_strpos($seoPathInfo$baseUrl) === 0;
  290.     }
  291. }