Passed
Push — trunk ( dd8a19...7fc211 )
by Christian
14:51 queued 18s
created

RequestTransformer   A

Complexity

Total Complexity 30

Size/Duplication

Total Lines 327
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 128
dl 0
loc 327
rs 10
c 0
b 0
f 0
wmc 30

9 Methods

Rating   Name   Duplication   Size   Complexity  
A extractInheritableAttributes() 0 14 3
A getSchemeAndHttpHost() 0 3 1
A __construct() 0 2 1
A findSalesChannel() 0 41 6
B transform() 0 116 8
A isSalesChannelRequired() 0 17 5
A equalsBaseUrl() 0 3 1
A resolveSeoUrl() 0 21 3
A containsBaseUrl() 0 3 2
1
<?php declare(strict_types=1);
2
3
namespace Shopware\Storefront\Framework\Routing;
4
5
use Shopware\Core\Content\Seo\AbstractSeoResolver;
6
use Shopware\Core\Framework\Routing\RequestTransformerInterface;
7
use Shopware\Core\PlatformRequest;
8
use Shopware\Core\SalesChannelRequest;
9
use Shopware\Storefront\Framework\Routing\Exception\SalesChannelMappingException;
10
use Symfony\Component\HttpFoundation\Request;
11
12
/**
13
 * @package storefront
14
 *
15
 * @phpstan-import-type Domain from AbstractDomainLoader
16
 * @phpstan-import-type ResolvedSeoUrl from AbstractSeoResolver
17
 */
18
class RequestTransformer implements RequestTransformerInterface
19
{
20
    final public const REQUEST_TRANSFORMER_CACHE_KEY = CachedDomainLoader::CACHE_KEY;
21
22
    /**
23
     * Virtual path of the "domain"
24
     *
25
     * @example
26
     * - `/de`
27
     * - `/en`
28
     * - {empty} - the virtual path is optional
29
     */
30
    final public const SALES_CHANNEL_BASE_URL = 'sw-sales-channel-base-url';
31
32
    /**
33
     * Scheme + Host + port + subdir in web root
34
     *
35
     * @example
36
     * - `https://shop.example` - no subdir
37
     * - `http://localhost:8000/subdir` - with sub dir `/subdir`
38
     */
39
    final public const SALES_CHANNEL_ABSOLUTE_BASE_URL = 'sw-sales-channel-absolute-base-url';
40
41
    /**
42
     * Scheme + Host + port + subdir in web root + virtual path
43
     *
44
     * @example
45
     * - `https://shop.example` - no sub dir and no virtual path
46
     * - `https://shop.example/en` - no sub dir and virtual path `/en`
47
     * - `http://localhost:8000/subdir` - with sub directory `/subdir`
48
     * - `http://localhost:8000/subdir/de` - with sub directory `/subdir` and virtual path `/de`
49
     */
50
    final public const STOREFRONT_URL = 'sw-storefront-url';
51
52
    final public const SALES_CHANNEL_RESOLVED_URI = 'resolved-uri';
53
54
    final public const ORIGINAL_REQUEST_URI = 'sw-original-request-uri';
55
56
    private const INHERITABLE_ATTRIBUTE_NAMES = [
57
        self::SALES_CHANNEL_BASE_URL,
58
        self::SALES_CHANNEL_ABSOLUTE_BASE_URL,
59
        self::STOREFRONT_URL,
60
        self::SALES_CHANNEL_RESOLVED_URI,
61
62
        PlatformRequest::ATTRIBUTE_SALES_CHANNEL_ID,
63
        SalesChannelRequest::ATTRIBUTE_IS_SALES_CHANNEL_REQUEST,
64
65
        SalesChannelRequest::ATTRIBUTE_DOMAIN_LOCALE,
66
        SalesChannelRequest::ATTRIBUTE_DOMAIN_SNIPPET_SET_ID,
67
        SalesChannelRequest::ATTRIBUTE_DOMAIN_CURRENCY_ID,
68
        SalesChannelRequest::ATTRIBUTE_DOMAIN_ID,
69
70
        SalesChannelRequest::ATTRIBUTE_THEME_ID,
71
        SalesChannelRequest::ATTRIBUTE_THEME_NAME,
72
        SalesChannelRequest::ATTRIBUTE_THEME_BASE_NAME,
73
74
        SalesChannelRequest::ATTRIBUTE_CANONICAL_LINK,
75
    ];
76
77
    /**
78
     * @var array<string>
79
     */
80
    private array $whitelist = [
81
        '/_wdt/',
82
        '/_profiler/',
83
        '/_error/',
84
        '/payment/finalize-transaction',
85
        '/installer',
86
    ];
87
88
    /**
89
     * @internal
90
     *
91
     * @param list<string> $registeredApiPrefixes
0 ignored issues
show
Bug introduced by
The type Shopware\Storefront\Framework\Routing\list was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

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

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
301
     */
302
    private function resolveSeoUrl(Request $request, string $baseUrl, string $languageId, string $salesChannelId): array
303
    {
304
        $seoPathInfo = $request->getPathInfo();
305
306
        // only remove full base url not part
307
        // registered domain: 'shop-dev.de/de'
308
        // incoming request:  'shop-dev.de/detail'
309
        // without leading slash, detail would be stripped
310
        $baseUrl = rtrim($baseUrl, '/') . '/';
311
312
        if ($this->equalsBaseUrl($seoPathInfo, $baseUrl)) {
313
            $seoPathInfo = '';
314
        } elseif ($this->containsBaseUrl($seoPathInfo, $baseUrl)) {
315
            $seoPathInfo = mb_substr($seoPathInfo, mb_strlen($baseUrl));
316
        }
317
318
        $resolved = $this->resolver->resolve($languageId, $salesChannelId, $seoPathInfo);
319
320
        $resolved['pathInfo'] = '/' . ltrim($resolved['pathInfo'], '/');
321
322
        return $resolved;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $resolved returns the type array which is incompatible with the documented return type Shopware\Storefront\Fram...\Routing\ResolvedSeoUrl.
Loading history...
323
    }
324
325
    private function getSchemeAndHttpHost(Request $request): string
326
    {
327
        return $request->getScheme() . '://' . idn_to_utf8($request->getHttpHost());
328
    }
329
330
    /**
331
     * We add the trailing slash to the base url
332
     * so we have to add it to the path info too, to check if they are equal
333
     */
334
    private function equalsBaseUrl(string $seoPathInfo, string $baseUrl): bool
335
    {
336
        return $baseUrl === rtrim($seoPathInfo, '/') . '/';
337
    }
338
339
    /**
340
     * We don't have to add the trailing slash when we check if the pathInfo contains teh base url
341
     */
342
    private function containsBaseUrl(string $seoPathInfo, string $baseUrl): bool
343
    {
344
        return !empty($baseUrl) && mb_strpos($seoPathInfo, $baseUrl) === 0;
345
    }
346
}
347