1
|
|
|
<?php declare(strict_types=1); |
2
|
|
|
/** |
3
|
|
|
* Shopware 5 |
4
|
|
|
* Copyright (c) shopware AG |
5
|
|
|
* |
6
|
|
|
* According to our dual licensing model, this program can be used either |
7
|
|
|
* under the terms of the GNU Affero General Public License, version 3, |
8
|
|
|
* or under a proprietary license. |
9
|
|
|
* |
10
|
|
|
* The texts of the GNU Affero General Public License with an additional |
11
|
|
|
* permission and of our proprietary license can be found at and |
12
|
|
|
* in the LICENSE file you have received along with this program. |
13
|
|
|
* |
14
|
|
|
* This program is distributed in the hope that it will be useful, |
15
|
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of |
16
|
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
17
|
|
|
* GNU Affero General Public License for more details. |
18
|
|
|
* |
19
|
|
|
* "Shopware" is a registered trademark of shopware AG. |
20
|
|
|
* The licensing of the program under the AGPLv3 does not imply a |
21
|
|
|
* trademark license. Therefore any rights, title and interest in |
22
|
|
|
* our trademarks remain entirely with us. |
23
|
|
|
*/ |
24
|
|
|
|
25
|
|
|
namespace Shopware\Framework\Routing; |
26
|
|
|
|
27
|
|
|
use Psr\Cache\CacheItemPoolInterface; |
28
|
|
|
use Psr\Log\LoggerInterface; |
29
|
|
|
use Ramsey\Uuid\Uuid; |
30
|
|
|
use Shopware\Context\Struct\ShopContext; |
31
|
|
|
use Shopware\Defaults; |
32
|
|
|
use Shopware\Kernel; |
33
|
|
|
use Symfony\Component\DependencyInjection\ContainerInterface; |
34
|
|
|
use Symfony\Component\HttpFoundation\Request; |
35
|
|
|
use Symfony\Component\Routing\Exception\ResourceNotFoundException; |
36
|
|
|
use Symfony\Component\Routing\Generator\UrlGenerator; |
37
|
|
|
use Symfony\Component\Routing\Matcher\RequestMatcherInterface; |
38
|
|
|
use Symfony\Component\Routing\Matcher\UrlMatcher; |
39
|
|
|
use Symfony\Component\Routing\RequestContext; |
40
|
|
|
use Symfony\Component\Routing\Route; |
41
|
|
|
use Symfony\Component\Routing\RouteCollection; |
42
|
|
|
use Symfony\Component\Routing\RouterInterface; |
43
|
|
|
|
44
|
|
|
class Router implements RouterInterface, RequestMatcherInterface |
45
|
|
|
{ |
46
|
|
|
public const SEO_REDIRECT_URL = 'seo_redirect_url'; |
47
|
|
|
public const IS_API_REQUEST_ATTRIBUTE = 'is_api'; |
48
|
|
|
public const REQUEST_TYPE_ATTRIBUTE = '_request_type'; |
49
|
|
|
public const REQUEST_TYPE_STOREFRONT = 'storefront'; |
50
|
|
|
public const REQUEST_TYPE_API = 'api'; |
51
|
|
|
public const REQUEST_TYPE_ADMINISTRATION = 'administration'; |
52
|
|
|
|
53
|
|
|
/** |
54
|
|
|
* Generates an seo URL, e.g. "http://example.com/example-category". |
55
|
|
|
*/ |
56
|
|
|
public const SEO_URL = 5; |
57
|
|
|
|
58
|
|
|
/** |
59
|
|
|
* @var RequestContext |
60
|
|
|
*/ |
61
|
|
|
private $context; |
62
|
|
|
|
63
|
|
|
/** |
64
|
|
|
* @var RouteCollection |
65
|
|
|
*/ |
66
|
|
|
private $routes; |
67
|
|
|
|
68
|
|
|
/** |
69
|
|
|
* @var string |
70
|
|
|
*/ |
71
|
|
|
private $resource; |
72
|
|
|
|
73
|
|
|
/** |
74
|
|
|
* @var LoggerInterface |
75
|
|
|
*/ |
76
|
|
|
private $logger; |
77
|
|
|
|
78
|
|
|
/** |
79
|
|
|
* @var ShopFinder |
80
|
|
|
*/ |
81
|
|
|
private $shopFinder; |
82
|
|
|
|
83
|
|
|
/** |
84
|
|
|
* @var UrlResolverInterface |
85
|
|
|
*/ |
86
|
|
|
private $urlResolver; |
87
|
|
|
|
88
|
|
|
/** |
89
|
|
|
* @var \Symfony\Component\HttpKernel\Bundle\BundleInterface[] |
90
|
|
|
*/ |
91
|
|
|
private $bundles; |
92
|
|
|
|
93
|
|
|
/** |
94
|
|
|
* @var CacheItemPoolInterface |
95
|
|
|
*/ |
96
|
|
|
private $cache; |
97
|
|
|
|
98
|
|
|
/** |
99
|
|
|
* @var ContainerInterface |
100
|
|
|
*/ |
101
|
|
|
private $container; |
102
|
|
|
|
103
|
|
|
public function __construct( |
104
|
|
|
ContainerInterface $container, |
105
|
|
|
$resource, |
106
|
72 |
|
Kernel $kernel, |
107
|
|
|
?RequestContext $context = null, |
108
|
|
|
LoggerInterface $logger = null, |
109
|
|
|
UrlResolverInterface $urlResolver, |
110
|
|
|
ShopFinder $shopFinder, |
111
|
|
|
CacheItemPoolInterface $cache |
112
|
|
|
) { |
113
|
|
|
$this->resource = $resource; |
114
|
|
|
$this->context = $context; |
115
|
|
|
$this->logger = $logger; |
116
|
|
|
|
117
|
72 |
|
$this->bundles = $kernel->getBundles(); |
118
|
72 |
|
$this->urlResolver = $urlResolver; |
119
|
72 |
|
$this->shopFinder = $shopFinder; |
120
|
|
|
$this->cache = $cache; |
121
|
72 |
|
$this->container = $container; |
122
|
72 |
|
} |
123
|
72 |
|
|
124
|
72 |
|
public function setContext(RequestContext $context): void |
125
|
72 |
|
{ |
126
|
72 |
|
$this->context = $context; |
127
|
72 |
|
} |
128
|
|
|
|
129
|
|
|
public function getContext(): ?RequestContext |
130
|
|
|
{ |
131
|
|
|
return $this->context; |
132
|
|
|
} |
133
|
|
|
|
134
|
19 |
|
/** |
135
|
|
|
* @return RouteCollection |
136
|
19 |
|
*/ |
137
|
|
|
public function getRouteCollection(): RouteCollection |
138
|
|
|
{ |
139
|
|
|
if ($this->routes !== null) { |
140
|
|
|
return $this->routes; |
141
|
|
|
} |
142
|
19 |
|
|
143
|
|
|
if ($this->container->getParameter('kernel.environment') !== 'prod') { |
144
|
19 |
|
$this->routes = $this->loadRoutes(); |
145
|
|
|
|
146
|
|
|
return $this->routes; |
147
|
|
|
} |
148
|
19 |
|
|
149
|
19 |
|
$cacheItem = $this->cache->getItem('router_routes'); |
150
|
|
|
if ($routes = $cacheItem->get()) { |
151
|
19 |
|
return $this->routes = $routes; |
152
|
|
|
} |
153
|
|
|
|
154
|
|
|
$this->routes = $this->loadRoutes(); |
155
|
|
|
$cacheItem->set($this->routes); |
156
|
|
|
$this->cache->save($cacheItem); |
157
|
|
|
|
158
|
|
|
return $this->routes; |
159
|
|
|
} |
160
|
|
|
|
161
|
|
|
public function generate($name, $parameters = [], $referenceType = self::ABSOLUTE_PATH): string |
162
|
|
|
{ |
163
|
|
|
$generator = new UrlGenerator( |
164
|
|
|
$this->getRouteCollection(), |
165
|
|
|
$this->getContext(), |
166
|
|
|
$this->logger |
167
|
|
|
); |
168
|
|
|
|
169
|
|
|
if (!$context = $this->getContext()) { |
170
|
|
|
return $generator->generate($name, $parameters, $referenceType); |
171
|
|
|
} |
172
|
|
|
|
173
|
|
|
if (!$shop = $context->getParameter('router_shop')) { |
174
|
|
|
return $generator->generate($name, $parameters, $referenceType); |
175
|
|
|
} |
176
|
|
|
|
177
|
|
|
$route = $this->getRouteCollection()->get($name); |
178
|
|
|
if ($route && $route->getOption('seo') !== true) { |
179
|
|
|
return $generator->generate($name, $parameters, $referenceType); |
180
|
|
|
} |
181
|
|
|
|
182
|
|
|
if ($referenceType !== self::SEO_URL) { |
183
|
|
|
//generate new url with shop base path/url |
184
|
|
|
$url = $generator->generate($name, $parameters, $referenceType); |
185
|
|
|
|
186
|
|
|
return rtrim($url, '/'); |
187
|
|
|
} |
188
|
|
|
|
189
|
|
|
//rewrite base url for url generator |
190
|
|
|
$stripBaseUrl = $this->rewriteBaseUrl($shop['base_url'], $shop['base_path']); |
191
|
|
|
|
192
|
|
|
//find seo url for path info |
193
|
|
|
$pathInfo = $generator->generate($name, $parameters, UrlGenerator::ABSOLUTE_PATH); |
194
|
|
|
if ($stripBaseUrl !== '/') { |
195
|
|
|
$pathInfo = str_replace($stripBaseUrl, '', $pathInfo); |
196
|
|
|
} |
197
|
|
|
|
198
|
|
|
$pathInfo = '/' . trim($pathInfo, '/'); |
199
|
|
|
|
200
|
|
|
$shopContext = new ShopContext( |
201
|
|
|
$shop['id'], |
202
|
|
|
json_decode($shop['shop_catalog_ids'], true), |
203
|
|
|
[], |
204
|
|
|
$shop['currency_id'], |
205
|
|
|
Defaults::LANGUAGE, |
206
|
|
|
null, |
207
|
|
|
Defaults::LIVE_VERSION, |
208
|
|
|
(float) $shop['currency_factor'] |
209
|
|
|
); |
210
|
|
|
|
211
|
|
|
$seoUrl = $this->urlResolver->getUrl($shop['id'], $pathInfo, $shopContext); |
212
|
|
|
|
213
|
|
|
//generate new url with shop base path/url |
214
|
|
|
$url = $generator->generate($name, $parameters, $referenceType); |
215
|
|
|
|
216
|
|
|
if ($seoUrl) { |
217
|
|
|
$seoPathInfo = '/' . ltrim($seoUrl->getSeoPathInfo(), '/'); |
218
|
|
|
$url = str_replace($pathInfo, $seoPathInfo, $url); |
219
|
|
|
} |
220
|
|
|
|
221
|
|
|
return rtrim($url, '/'); |
222
|
|
|
} |
223
|
|
|
|
224
|
|
|
public function match($pathinfo) |
225
|
|
|
{ |
226
|
|
|
$pathinfo = '/' . trim($pathinfo, '/'); |
227
|
|
|
|
228
|
|
|
$this->context->setPathInfo($pathinfo); |
229
|
|
|
|
230
|
|
|
$matcher = new UrlMatcher($this->getRouteCollection(), $this->getContext()); |
231
|
|
|
|
232
|
|
|
return $matcher->match($pathinfo); |
233
|
|
|
} |
234
|
19 |
|
|
235
|
|
|
public function matchRequest(Request $request): array |
236
|
19 |
|
{ |
237
|
|
|
$requestStack = $this->container->get('request_stack'); |
238
|
19 |
|
$master = $requestStack->getMasterRequest(); |
239
|
|
|
|
240
|
19 |
|
if ($master !== null && $master->attributes->has('router_shop')) { |
241
|
|
|
$shop = $master->attributes->get('router_shop'); |
242
|
19 |
|
} else { |
243
|
|
|
$shop = $this->shopFinder->findShopByRequest($this->context, $request); |
244
|
|
|
} |
245
|
19 |
|
|
246
|
|
|
$pathInfo = $this->context->getPathInfo(); |
247
|
19 |
|
|
248
|
19 |
|
// save decision which type of request is called (storefront, api, administration) |
249
|
|
|
$requestType = $this->getRequestType($request); |
250
|
19 |
|
|
251
|
|
|
$request->attributes->set(self::REQUEST_TYPE_ATTRIBUTE, $requestType); |
252
|
|
|
$request->attributes->set( |
253
|
19 |
|
self::IS_API_REQUEST_ATTRIBUTE, |
254
|
|
|
in_array($requestType, [self::REQUEST_TYPE_ADMINISTRATION, self::REQUEST_TYPE_API], true) |
255
|
|
|
); |
256
|
19 |
|
|
257
|
|
|
if (!$shop) { |
258
|
|
|
return $this->match($pathInfo); |
259
|
19 |
|
} |
260
|
|
|
|
261
|
19 |
|
//save detected shop to context for further processes |
262
|
19 |
|
$currencyId = $this->getCurrencyId($request, $shop['currency_id']); |
263
|
19 |
|
|
264
|
19 |
|
$this->context->setParameter('router_shop', $shop); |
265
|
|
|
$request->attributes->set('router_shop', $shop); |
266
|
|
|
$request->attributes->set('_shop_id', $shop['id']); |
267
|
19 |
|
$request->attributes->set('_currency_id', $currencyId); |
268
|
|
|
$request->attributes->set('_locale_id', $shop['locale_id']); |
269
|
|
|
$request->setLocale($shop['locale_code']); |
270
|
|
|
|
271
|
|
|
$stripBaseUrl = $this->rewriteBaseUrl($shop['base_url'], $shop['base_path']); |
272
|
19 |
|
|
273
|
|
|
// strip base url from path info |
274
|
19 |
|
$pathInfo = $request->getBaseUrl() . $request->getPathInfo(); |
275
|
19 |
|
$pathInfo = preg_replace('#^' . $stripBaseUrl . '#i', '', $pathInfo); |
276
|
19 |
|
$pathInfo = '/' . trim($pathInfo, '/'); |
277
|
19 |
|
|
278
|
19 |
|
if (strpos($pathInfo, '/widgets/') !== false) { |
279
|
19 |
|
return $this->match($pathInfo); |
280
|
|
|
} |
281
|
19 |
|
|
282
|
|
|
if ($request->attributes->get(self::IS_API_REQUEST_ATTRIBUTE)) { |
283
|
|
|
try { |
284
|
19 |
|
$match = $this->match($pathInfo); |
285
|
19 |
|
} catch (ResourceNotFoundException $e) { |
286
|
19 |
|
return $this->matchDynamicApi($pathInfo); |
287
|
|
|
} |
288
|
19 |
|
|
289
|
|
|
return $match; |
290
|
|
|
} |
291
|
|
|
|
292
|
19 |
|
$shopContext = new ShopContext( |
293
|
19 |
|
$shop['id'], |
294
|
|
|
json_decode($shop['shop_catalog_ids'], true), |
295
|
|
|
[], |
296
|
|
|
$shop['currency_id'], |
297
|
|
|
Defaults::LANGUAGE, |
298
|
|
|
null, |
299
|
|
|
Defaults::LIVE_VERSION, |
300
|
|
|
(float) $shop['currency_factor'] |
301
|
|
|
); |
302
|
|
|
|
303
|
|
|
//resolve seo urls to use symfony url matcher for route detection |
304
|
|
|
$seoUrl = $this->urlResolver->getPathInfo($shop['id'], $pathInfo, $shopContext); |
305
|
|
|
|
306
|
|
|
if (!$seoUrl) { |
307
|
|
|
try { |
308
|
|
|
return $this->match($pathInfo); |
309
|
|
|
} catch (\Exception $e) { |
310
|
|
|
if ($requestType === self::REQUEST_TYPE_STOREFRONT) { |
311
|
|
|
return $this->match('/'); |
312
|
|
|
} |
313
|
|
|
throw $e; |
314
|
|
|
} |
315
|
|
|
} |
316
|
|
|
|
317
|
|
|
$pathInfo = $seoUrl->getPathInfo(); |
318
|
|
|
if (!$seoUrl->getIsCanonical()) { |
319
|
|
|
$redirectUrl = $this->urlResolver->getUrl($shop['id'], $seoUrl->getPathInfo(), $shopContext); |
320
|
|
|
$request->attributes->set(self::SEO_REDIRECT_URL, $redirectUrl); |
321
|
|
|
} |
322
|
|
|
|
323
|
|
|
try { |
324
|
|
|
return $this->match($pathInfo); |
325
|
|
|
} catch (\Exception $e) { |
326
|
|
|
if ($requestType === self::REQUEST_TYPE_STOREFRONT) { |
327
|
|
|
return $this->match('/'); |
328
|
|
|
} |
329
|
|
|
throw $e; |
330
|
|
|
} |
331
|
|
|
} |
332
|
|
|
|
333
|
|
|
public function assemble(string $url): string |
334
|
|
|
{ |
335
|
|
|
$generator = new UrlGenerator( |
336
|
|
|
$this->getRouteCollection(), |
337
|
|
|
$this->getContext(), |
338
|
|
|
$this->logger |
339
|
|
|
); |
340
|
|
|
|
341
|
|
|
$base = $generator->generate('homepage', [], UrlGenerator::ABSOLUTE_URL); |
342
|
|
|
|
343
|
|
|
return rtrim($base, '/') . '/' . ltrim($url, '/'); |
344
|
|
|
} |
345
|
|
|
|
346
|
|
|
protected function getCurrencyId(Request $request, string $fallback): string |
347
|
|
|
{ |
348
|
|
|
if ($this->context->getMethod() === 'POST' && $request->get('__currency')) { |
349
|
|
|
return (string) $request->get('__currency'); |
350
|
19 |
|
} |
351
|
|
|
|
352
|
19 |
|
if ($request->cookies->has('currency')) { |
353
|
|
|
return (string) $request->cookies->get('currency'); |
354
|
|
|
} |
355
|
|
|
|
356
|
19 |
|
if ($request->attributes->has('_currency_id')) { |
357
|
19 |
|
return (string) $request->attributes->get('_currency_id'); |
358
|
|
|
} |
359
|
|
|
|
360
|
|
|
return $fallback; |
361
|
|
|
} |
362
|
|
|
|
363
|
|
|
private function loadRoutes(): RouteCollection |
364
|
|
|
{ |
365
|
|
|
$routeCollection = new RouteCollection(); |
366
|
|
|
|
367
|
19 |
|
if (file_exists($this->resource)) { |
368
|
|
|
$routeCollection->addCollection( |
369
|
19 |
|
$this->container->get('routing.loader')->load($this->resource) |
370
|
|
|
); |
371
|
19 |
|
} |
372
|
|
|
|
373
|
|
|
foreach ($this->bundles as $bundle) { |
374
|
|
|
if (!file_exists($bundle->getPath() . '/Controller')) { |
375
|
|
|
continue; |
376
|
|
|
} |
377
|
19 |
|
|
378
|
19 |
|
$routeCollection->addCollection( |
379
|
19 |
|
$this->container->get('routing.loader')->import($bundle->getPath() . '/Controller/', 'annotation') |
380
|
|
|
); |
381
|
|
|
} |
382
|
19 |
|
|
383
|
19 |
|
return $routeCollection; |
384
|
|
|
} |
385
|
|
|
|
386
|
|
|
private function rewriteBaseUrl(?string $baseUrl, string $basePath): string |
387
|
19 |
|
{ |
388
|
19 |
|
//generate new path info for detected shop |
389
|
|
|
$stripBaseUrl = $baseUrl ?? $basePath; |
390
|
|
|
$stripBaseUrl = rtrim($stripBaseUrl, '/') . '/'; |
391
|
19 |
|
|
392
|
|
|
//rewrite base url for url generator |
393
|
|
|
$this->context->setBaseUrl(rtrim($stripBaseUrl, '/')); |
394
|
19 |
|
|
395
|
|
|
return $stripBaseUrl; |
396
|
|
|
} |
397
|
19 |
|
|
398
|
19 |
|
private function getRequestType(Request $request): string |
399
|
|
|
{ |
400
|
|
|
$isApi = stripos($request->getPathInfo(), '/api/') === 0; |
401
|
19 |
|
|
402
|
|
|
if ($isApi && $request->query->has('administration')) { |
403
|
19 |
|
return self::REQUEST_TYPE_ADMINISTRATION; |
404
|
|
|
} |
405
|
|
|
if ($isApi) { |
406
|
19 |
|
return self::REQUEST_TYPE_API; |
407
|
|
|
} |
408
|
19 |
|
|
409
|
|
|
return self::REQUEST_TYPE_STOREFRONT; |
410
|
19 |
|
} |
411
|
|
|
|
412
|
|
|
private function matchDynamicApi(string $path) |
413
|
19 |
|
{ |
414
|
19 |
|
$apiRoutes = $this->createApiRouteCollection(); |
415
|
|
|
|
416
|
|
|
if (Uuid::isValid(basename($path))) { |
417
|
|
|
$apiRoutes->remove('api_controller.list'); |
418
|
|
|
} else { |
419
|
|
|
$apiRoutes->remove('api_controller.detail'); |
420
|
|
|
} |
421
|
|
|
|
422
|
|
|
$matcher = new UrlMatcher($apiRoutes, $this->getContext()); |
423
|
|
|
|
424
|
|
|
return $matcher->match($path); |
425
|
|
|
} |
426
|
|
|
|
427
|
|
|
private function createApiRouteCollection(): RouteCollection |
428
|
|
|
{ |
429
|
|
|
$collection = new RouteCollection(); |
430
|
|
|
|
431
|
|
|
$class = 'Shopware\Rest\Controller\ApiController'; |
432
|
|
|
|
433
|
|
|
$route = new Route('/api/{path}'); |
434
|
|
|
$route->setMethods(['GET']); |
435
|
|
|
$route->setDefault('_controller', $class . ':listAction'); |
436
|
|
|
$route->addRequirements(['path' => '.*']); |
437
|
|
|
$collection->add('api_controller.list', $route); |
438
|
|
|
|
439
|
|
|
$route = new Route('/api/{path}'); |
440
|
|
|
$route->setMethods(['GET']); |
441
|
|
|
$route->setDefault('_controller', $class . '::detailAction'); |
442
|
|
|
$route->addRequirements(['path' => '.*']); |
443
|
|
|
$collection->add('api_controller.detail', $route); |
444
|
|
|
|
445
|
|
|
$route = new Route('/api/search/{path}'); |
446
|
|
|
$route->setMethods(['POST']); |
447
|
|
|
$route->setDefault('_controller', $class . '::searchAction'); |
448
|
|
|
$route->addRequirements(['path' => '.*']); |
449
|
|
|
$collection->add('api_controller.search', $route); |
450
|
|
|
|
451
|
|
|
$route = new Route('/api/{path}'); |
452
|
|
|
$route->setMethods(['POST']); |
453
|
|
|
$route->setDefault('_controller', $class . '::createAction'); |
454
|
|
|
$route->addRequirements(['path' => '.*']); |
455
|
|
|
$collection->add('api_controller.create', $route); |
456
|
|
|
|
457
|
|
|
$route = new Route('/api/{path}'); |
458
|
|
|
$route->setMethods(['PATCH']); |
459
|
|
|
$route->setDefault('_controller', $class . '::updateAction'); |
460
|
|
|
$route->addRequirements(['path' => '.*']); |
461
|
|
|
$collection->add('api_controller.update', $route); |
462
|
|
|
|
463
|
|
|
$route = new Route('/api/{path}'); |
464
|
|
|
$route->setMethods(['DELETE']); |
465
|
|
|
$route->setDefault('_controller', $class . '::deleteAction'); |
466
|
|
|
$route->addRequirements(['path' => '.*']); |
467
|
|
|
$collection->add('api_controller.delete', $route); |
468
|
|
|
|
469
|
|
|
return $collection; |
470
|
|
|
} |
471
|
|
|
} |
472
|
|
|
|