Passed
Pull Request — master (#2755)
by Rafael
03:32
created

RoutingService::unmaskQueryParameters()   B

Complexity

Conditions 8
Paths 15

Size

Total Lines 42
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 72

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 23
c 1
b 0
f 0
dl 0
loc 42
ccs 0
cts 33
cp 0
rs 8.4444
cc 8
nc 15
nop 1
crap 72
1
<?php
2
declare(strict_types=1);
3
4
/*
5
 * This file is part of the TYPO3 CMS project.
6
 *
7
 * It is free software; you can redistribute it and/or modify it under
8
 * the terms of the GNU General Public License, either version 2
9
 * of the License, or any later version.
10
 *
11
 * For the full copyright and license information, please read the
12
 * LICENSE.txt file that was distributed with this source code.
13
 *
14
 * The TYPO3 project - inspiring people to share!
15
 */
16
17
namespace ApacheSolrForTypo3\Solr\Routing;
18
19
use ApacheSolrForTypo3\Solr\Routing\Enhancer\SolrRouteEnhancerInterface;
20
use Psr\Http\Message\ServerRequestInterface;
21
use Psr\Http\Message\UriInterface;
22
use Psr\Log\LoggerAwareInterface;
23
use Psr\Log\LoggerAwareTrait;
24
use TYPO3\CMS\Core\Context\Context;
25
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
26
use TYPO3\CMS\Core\Http\Uri;
27
use TYPO3\CMS\Core\Routing\PageSlugCandidateProvider;
28
use TYPO3\CMS\Core\Routing\SiteMatcher;
29
use TYPO3\CMS\Core\Site\Entity\NullSite;
30
use TYPO3\CMS\Core\Site\Entity\Site;
31
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
32
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
33
use TYPO3\CMS\Core\Site\SiteFinder;
34
use TYPO3\CMS\Core\Utility\GeneralUtility;
35
36
/**
37
 * This service class bundles method required to process and manipulate routes.
38
 *
39
 * @author Lars Tode <[email protected]>
40
 */
41
class RoutingService implements LoggerAwareInterface
42
{
43
    use LoggerAwareTrait;
44
45
    /**
46
     * Default plugin namespace
47
     */
48
    const PLUGIN_NAMESPACE = 'tx_solr';
49
50
    /**
51
     * Settings from routing configuration
52
     *
53
     * @var array
54
     */
55
    protected $settings = [];
56
57
    /**
58
     * List of filter that are placed as path arguments
59
     *
60
     * @var array
61
     */
62
    protected $pathArguments = [];
63
64
    /**
65
     * Plugin/extension namespace
66
     *
67
     * @var string
68
     */
69
    protected $pluginNamespace = 'tx_solr';
70
71
    /**
72
     * List of TYPO3 core parameters, that we should ignore
73
     *
74
     * @see \TYPO3\CMS\Frontend\Page\CacheHashCalculator::isCoreParameter
75
     * @var string[]
76
     */
77
    protected $coreParameters = ['no_cache', 'cHash', 'id', 'MP', 'type'];
78
79
    /**
80
     * @var UrlFacetService
81
     */
82
    protected $urlFacetPathService;
83
84
    /**
85
     * @var UrlFacetService
86
     */
87
    protected $urlFacetQueryService;
88
89
    /**
90
     * RoutingService constructor.
91
     *
92
     * @param array $settings
93
     * @param string $pluginNamespace
94
     */
95 35
    public function __construct(array $settings = [], string $pluginNamespace = self::PLUGIN_NAMESPACE)
96
    {
97 35
        $this->settings = $settings;
98 35
        $this->pluginNamespace = $pluginNamespace;
99 35
        if (empty($this->pluginNamespace)) {
100
            $this->pluginNamespace = self::PLUGIN_NAMESPACE;
101
        }
102 35
        $this->initUrlFacetService();
103 35
    }
104
105
    /**
106
     * Creates a clone of the current service and replace the settings inside
107
     *
108
     * @param array $settings
109
     * @return RoutingService
110
     */
111
    public function withSettings(array $settings): RoutingService
112
    {
113
        $service = clone $this;
114
        $service->settings = $settings;
115
        $service->initUrlFacetService();
116
        return $service;
117
    }
118
119
    /**
120
     * Creates a clone of the current service and replace the settings inside
121
     *
122
     * @param array $pathArguments
123
     * @return RoutingService
124
     */
125
    public function withPathArguments(array $pathArguments): RoutingService
126
    {
127
        $service = clone $this;
128
        $service->pathArguments = $pathArguments;
129
        $service->initUrlFacetService();
130
        return $service;
131
    }
132
133
    /**
134
     * Load configuration from routing configuration
135
     *
136
     * @param array $routingConfiguration
137
     * @return $this
138
     */
139
    public function fromRoutingConfiguration(array $routingConfiguration): RoutingService
140
    {
141
        if (empty($routingConfiguration) ||
142
            empty($routingConfiguration['type']) ||
143
            !$this->isRouteEnhancerForSolr((string)$routingConfiguration['type'])) {
144
            return $this;
145
        }
146
147
        if (isset($routingConfiguration['solr'])) {
148
            $this->settings = $routingConfiguration['solr'];
149
            $this->initUrlFacetService();
150
        }
151
152
        if (isset($routingConfiguration['_arguments'])) {
153
            $this->pathArguments = $routingConfiguration['_arguments'];
154
        }
155
156
        return $this;
157
    }
158
159
    /**
160
     * Reset the routing service
161
     *
162
     * @return $this
163
     */
164 35
    public function reset(): RoutingService
165
    {
166 35
        $this->settings = [];
167 35
        $this->pathArguments = [];
168 35
        $this->pluginNamespace = self::PLUGIN_NAMESPACE;
169 35
        return $this;
170
    }
171
172
    /**
173
     * Initialize url facet services for different types
174
     *
175
     * @return $this
176
     */
177 35
    protected function initUrlFacetService(): RoutingService
178
    {
179 35
        $this->urlFacetPathService = new UrlFacetService('path', $this->settings);
180 35
        $this->urlFacetQueryService = new UrlFacetService('query', $this->settings);
181
182 35
        return $this;
183
    }
184
185
    /**
186
     * @return UrlFacetService
187
     */
188
    public function getUrlFacetPathService(): UrlFacetService
189
    {
190
        return $this->urlFacetPathService;
191
    }
192
193
    /**
194
     * @return UrlFacetService
195
     */
196
    public function getUrlFacetQueryService(): UrlFacetService
197
    {
198
        return $this->urlFacetQueryService;
199
    }
200
201
    /**
202
     * Test if the given parameter is a Core parameter
203
     *
204
     * @see \TYPO3\CMS\Frontend\Page\CacheHashCalculator::isCoreParameter
205
     * @param string $parameterName
206
     * @return bool
207
     */
208
    public function isCoreParameter(string $parameterName): bool
209
    {
210
        return in_array($parameterName, $this->coreParameters);
211
    }
212
213
    /**
214
     * This returns the plugin namespace
215
     * @see https://docs.typo3.org/p/apache-solr-for-typo3/solr/master/en-us/Configuration/Reference/TxSolrView.html#pluginnamespace
216
     *
217
     * @return string
218
     */
219
    public function getPluginNamespace(): string
220
    {
221
        return $this->pluginNamespace;
222
    }
223
224
    /**
225
     * Determine if an enhancer is in use for Solr
226
     *
227
     * @param string $enhancerName
228
     * @return bool
229
     */
230
    public function isRouteEnhancerForSolr(string $enhancerName): bool
231
    {
232
        if (empty($enhancerName)) {
233
            return false;
234
        }
235
236
        if (!isset($GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['enhancers'][$enhancerName])) {
237
            return false;
238
        }
239
        $className = $GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['enhancers'][$enhancerName];
240
241
        if (!class_exists($className)) {
242
            return false;
243
        }
244
245
        $interfaces = class_implements($className);
246
247
        return in_array(SolrRouteEnhancerInterface::class, $interfaces);
248
    }
249
250
    /**
251
     * Masks Solr filter inside of the query parameters
252
     *
253
     * @param string $uriPath
254
     * @return string
255
     */
256
    public function finalizePathQuery(string $uriPath): string
257
    {
258
        $pathSegments = explode('/', $uriPath);
259
        $query = array_pop($pathSegments);
260
        $queryValues = explode($this->urlFacetPathService->getMultiValueSeparator(), $query);
261
        $queryValues = array_map([$this->urlFacetPathService, 'decodeSingleValue'], $queryValues);
262
        /*
263
         * In some constellations the path query contains the facet type in front.
264
         * This leads to the result, that the query values could contain the same facet value multiple times.
265
         *
266
         * In order to avoid this behaviour, the query values need to be checked and clean up.
267
         * 1. Remove possible prefix information
268
         * 2. Apply character replacements
269
         * 3. Filter duplicate values
270
         */
271
        $queryValuesCount = count($queryValues);
272
        for ($i = 0; $i < $queryValuesCount; $i++) {
273
            $queryValues[$i] = urldecode($queryValues[$i]);
274
            if ($this->containsFacetAndValueSeparator((string)$queryValues[$i])) {
275
                [$facetName, $facetValue] = explode(
276
                    $this->detectFacetAndValueSeparator((string)$queryValues[$i]),
277
                    (string)$queryValues[$i],
278
                    2
279
                );
280
281
                if ($this->isPathArgument((string)$facetName)) {
282
                    $queryValues[$i] = $facetValue;
283
                }
284
285
            }
286
            $queryValues[$i] = $this->urlFacetPathService->applyCharacterMap($queryValues[$i]);
287
        }
288
289
        $queryValues = array_unique($queryValues);
290
        $queryValues = array_map([$this->urlFacetPathService, 'encodeSingleValue'], $queryValues);
291
        sort($queryValues);
292
        $pathSegments[] = implode(
293
            $this->urlFacetPathService->getMultiValueSeparator(),
294
            $queryValues
295
        );
296
        return implode('/', $pathSegments);
297
    }
298
299
    /**
300
     * This method checks if the query parameter should be masked.
301
     *
302
     * @return bool
303
     */
304
    public function shouldMaskQueryParameter(): bool
305
    {
306
        if (!isset($this->settings['query']['mask']) ||
307
            !(bool)$this->settings['query']['mask']) {
308
            return false;
309
        }
310
311
        $targetFields = $this->getQueryParameterMap();
312
313
        return !empty($targetFields);
314
    }
315
316
    /**
317
     * Masks Solr filter inside of the query parameters
318
     *
319
     * @param array $queryParams
320
     * @return array
321
     */
322
    public function maskQueryParameters(array $queryParams): array
323
    {
324
        if (!$this->shouldMaskQueryParameter()) {
325
            return $queryParams;
326
        }
327
328
        if (!isset($queryParams[$this->getPluginNamespace()])) {
329
            $this->logger
330
                ->/** @scrutinizer ignore-call */
331
                error('Mask error: Query parameters has no entry for namespace ' . $this->getPluginNamespace());
332
            return $queryParams;
333
        }
334
335
        if (!isset($queryParams[$this->getPluginNamespace()]['filter'])) {
336
            $this->logger
337
                ->info('Mask info: Query parameters has no filter in namespace ' . $this->getPluginNamespace());
0 ignored issues
show
Bug introduced by
The method info() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

337
                ->/** @scrutinizer ignore-call */ 
338
                  info('Mask info: Query parameters has no filter in namespace ' . $this->getPluginNamespace());

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
338
            return $queryParams;
339
        }
340
        $queryParameterMap = $this->getQueryParameterMap();
341
        $newQueryParams = $queryParams;
342
343
        $newFilterArray = [];
344
        foreach ($newQueryParams[$this->getPluginNamespace()]['filter'] as $queryParamName => $queryParamValue) {
345
            $defaultSeparator = $this->detectFacetAndValueSeparator((string)$queryParamValue);
346
            [$facetName, $facetValue] = explode($defaultSeparator, $queryParamValue, 2);
347
            $keep = false;
348
            if (isset($queryParameterMap[$facetName]) &&
349
                isset($newQueryParams[$queryParameterMap[$facetName]])) {
350
                $this->logger->error(
351
                    'Mask error: Facet "' . $facetName . '" as "' . $queryParameterMap[$facetName] .
352
                    '" already in query!'
353
                );
354
                $keep = true;
355
            }
356
            if (!isset($queryParameterMap[$facetName]) || $keep) {
357
                $newFilterArray[] = $queryParamValue;
358
                continue;
359
            }
360
361
            $newQueryParams[$queryParameterMap[$facetName]] = $facetValue;
362
        }
363
364
        $newQueryParams[$this->getPluginNamespace()]['filter'] = $newFilterArray;
365
366
        return $this->cleanUpQueryParameters($newQueryParams);
367
    }
368
369
    /**
370
     * Unmask incoming parameters if needed
371
     *
372
     * @param array $queryParams
373
     * @return array
374
     */
375
    public function unmaskQueryParameters(array $queryParams): array
376
    {
377
        if (!$this->shouldMaskQueryParameter()) {
378
            return $queryParams;
379
        }
380
381
        /*
382
         * The array $queryParameterMap contains the mapping of
383
         * facet name to new url name. In order to unmask we need to switch key and values.
384
         */
385
        $queryParameterMap = $this->getQueryParameterMap();
386
        $queryParameterMapSwitched = [];
387
        foreach ($queryParameterMap as $value => $key) {
388
            $queryParameterMapSwitched[$key] = $value;
389
        }
390
391
        $newQueryParams = [];
392
        foreach ($queryParams as $queryParamName => $queryParamValue) {
393
            // A merge is needed!
394
            if (!isset($queryParameterMapSwitched[$queryParamName])) {
395
                if (isset($newQueryParams[$queryParamName])) {
396
                    $newQueryParams[$queryParamName] = array_merge_recursive(
397
                        $newQueryParams[$queryParamName],
398
                        $queryParamValue
399
                    );
400
                } else {
401
                    $newQueryParams[$queryParamName] = $queryParamValue;
402
                }
403
                continue;
404
            }
405
            if (!isset($newQueryParams[$this->getPluginNamespace()])) {
406
                $newQueryParams[$this->getPluginNamespace()] = [];
407
            }
408
            if (!isset($newQueryParams[$this->getPluginNamespace()]['filter'])) {
409
                $newQueryParams[$this->getPluginNamespace()]['filter'] = [];
410
            }
411
412
            $newQueryParams[$this->getPluginNamespace()]['filter'][] =
413
                $queryParameterMapSwitched[$queryParamName] . ':' . $queryParamValue;
414
        }
415
416
        return $this->cleanUpQueryParameters($newQueryParams);
417
    }
418
419
    /**
420
     * This method check if the query parameters should be touched or not.
421
     *
422
     * There are following requirements:
423
     * - Masking is activated and the mal is valid or
424
     * - Concat is activated
425
     *
426
     * @return bool
427
     */
428
    public function shouldConcatQueryParameters(): bool
429
    {
430
        /*
431
         * The concat will activate automatically if parameters should be masked.
432
         * This solution is less complex since not every mapping parameter needs to be tested
433
         */
434
        if ($this->shouldMaskQueryParameter()) {
435
            return true;
436
        }
437
438
        return isset($this->settings['query']['concat']) && (bool)$this->settings['query']['concat'];
439
    }
440
441
    /**
442
     * Returns the query parameter map
443
     *
444
     * Note TYPO3 core query arguments removed from the configured map!
445
     *
446
     * @return array
447
     */
448
    public function getQueryParameterMap(): array
449
    {
450
        if (!isset($this->settings['query']['map']) ||
451
            !is_array($this->settings['query']['map']) ||
452
            empty($this->settings['query']['map'])) {
453
            return [];
454
        }
455
        // TODO: Test if there is more than one value!
456
        $self = $this;
457
        return array_filter(
458
            $this->settings['query']['map'],
459
            function($value) use ($self) {
460
                return !$self->isCoreParameter($value);
461
            }
462
        );
463
    }
464
465
    /**
466
     * Group all filter values together and concat e
467
     * Note: this will just handle filter values
468
     *
469
     * IN:
470
     * tx_solr => [
471
     *   filter => [
472
     *      color:red
473
     *      product:candy
474
     *      color:blue
475
     *      taste:sour
476
     *   ]
477
     * ]
478
     *
479
     * OUT:
480
     * tx_solr => [
481
     *   filter => [
482
     *      color:blue,red
483
     *      product:candy
484
     *      taste:sour
485
     *   ]
486
     * ]
487
     * @param array $queryParams
488
     * @return array
489
     */
490
    public function concatQueryParameter(array $queryParams = []): array
491
    {
492
        if (!$this->shouldConcatQueryParameters()) {
493
            return $queryParams;
494
        }
495
496
        if (!isset($queryParams[$this->getPluginNamespace()])) {
497
            $this->logger
498
                ->error('Mask error: Query parameters has no entry for namespace ' . $this->getPluginNamespace());
499
            return $queryParams;
500
        }
501
502
        if (!isset($queryParams[$this->getPluginNamespace()]['filter'])) {
503
            $this->logger
504
                ->info('Mask info: Query parameters has no filter in namespace ' . $this->getPluginNamespace());
505
            return $queryParams;
506
        }
507
508
        $queryParams[$this->getPluginNamespace()]['filter'] =
509
            $this->concatFilterValues($queryParams[$this->getPluginNamespace()]['filter']);
510
511
        return $this->cleanUpQueryParameters($queryParams);
512
    }
513
514
    /**
515
     * This method expect a filter array that should be concat instead of the whole query
516
     *
517
     * @param array $filterArray
518
     * @return array
519
     */
520
    public function concatFilterValues(array $filterArray): array
521
    {
522
        if (empty($filterArray) || !$this->shouldConcatQueryParameters()) {
523
            return $filterArray;
524
        }
525
526
        $queryParameterMap = $this->getQueryParameterMap();
527
        $newFilterArray = [];
528
        $defaultSeparator = $this->detectFacetAndValueSeparator((string)$filterArray[0]);
529
        // Collect parameter names and rename parameter if required
530
        foreach ($filterArray as $set) {
531
            $separator = $this->detectFacetAndValueSeparator((string)$set);
532
            [$facetName, $facetValue] = explode($separator, $set, 2);
533
            if (isset($queryParameterMap[$facetName])) {
534
                $facetName = $queryParameterMap[$facetName];
535
            }
536
            if (!isset($newFilterArray[$facetName])) {
537
                $newFilterArray[$facetName] = [$facetValue];
538
            } else {
539
                $newFilterArray[$facetName][] = $facetValue;
540
            }
541
        }
542
543
        foreach ($newFilterArray as $facetName => $facetValues) {
544
            $newFilterArray[$facetName] = $facetName . $defaultSeparator . $this->queryParameterFacetsToString($facetValues);
545
        }
546
547
        return array_values($newFilterArray);
548
    }
549
550
    /**
551
     * Inflate given query parameters if configured
552
     * Note: this will just combine filter values
553
     *
554
     * IN:
555
     * tx_solr => [
556
     *   filter => [
557
     *      color:blue,red
558
     *      product:candy
559
     *      taste:sour
560
     *   ]
561
     * ]
562
     *
563
     * OUT:
564
     * tx_solr => [
565
     *   filter => [
566
     *      color:red
567
     *      product:candy
568
     *      color:blue
569
     *      taste:sour
570
     *   ]
571
     * ]
572
     *
573
     * @param array $queryParams
574
     * @return array
575
     */
576
    public function inflateQueryParameter(array $queryParams = []): array
577
    {
578
        if (!$this->shouldConcatQueryParameters()) {
579
            return $queryParams;
580
        }
581
582
        if (!isset($queryParams[$this->getPluginNamespace()])) {
583
            $queryParams[$this->getPluginNamespace()] = [];
584
        }
585
586
        if (!isset($queryParams[$this->getPluginNamespace()]['filter'])) {
587
            $queryParams[$this->getPluginNamespace()]['filter'] = [];
588
        }
589
590
        $newQueryParams = [];
591
        foreach ($queryParams[$this->getPluginNamespace()]['filter'] as $set) {
592
            $separator = $this->detectFacetAndValueSeparator((string)$set);
593
            [$facetName, $facetValuesString] = explode($separator, $set, 2);
594
            $facetValues = explode($this->urlFacetQueryService->getMultiValueSeparator(), $facetValuesString);
595
596
            /**
597
             * A facet value could contain the multi value separator. This value is masked in order to
598
             * avoid problems during separation of the values (line above).
599
             *
600
             * After splitting the values, the character inside the value need to be restored
601
             *
602
             * @see RoutingService::queryParameterFacetsToString
603
             */
604
            $facetValues = array_map([$this->urlFacetQueryService, 'decodeSingleValue'], $facetValues);
605
606
            foreach ($facetValues as $facetValue) {
607
                $newQueryParams[] = $facetName . $separator . $facetValue;
608
            }
609
        }
610
        $queryParams[$this->getPluginNamespace()]['filter'] = array_values($newQueryParams);
611
612
        return $this->cleanUpQueryParameters($queryParams);
613
    }
614
615
    /**
616
     * Cleanup the query parameters, to avoid empty solr arguments
617
     *
618
     * @param array $queryParams
619
     * @return array
620
     */
621
    public function cleanUpQueryParameters(array $queryParams): array
622
    {
623
        if (empty($queryParams[$this->getPluginNamespace()]['filter'])) {
624
            unset($queryParams[$this->getPluginNamespace()]['filter']);
625
        }
626
627
        if (empty($queryParams[$this->getPluginNamespace()])) {
628
            unset($queryParams[$this->getPluginNamespace()]);
629
        }
630
        return $queryParams;
631
    }
632
633
    /**
634
     * Builds a string out of multiple facet values
635
     *
636
     * A facet value could contain the multi value separator. This value have to masked in order to
637
     * avoid problems during separation of the values later.
638
     *
639
     * This mask have to apply before contact the values
640
     *
641
     * @param array $facets
642
     * @return string
643
     */
644
    public function queryParameterFacetsToString(array $facets): string
645
    {
646
        $facets = array_map([$this->urlFacetQueryService, 'encodeSingleValue'], $facets);
647
        sort($facets);
648
        return implode($this->urlFacetQueryService->getMultiValueSeparator(), $facets);
649
    }
650
651
    /**
652
     * Returns the string which separates the facet from the value
653
     *
654
     * @param string $facetWithValue
655
     * @return string
656
     */
657
    public function detectFacetAndValueSeparator(string $facetWithValue): string
658
    {
659
        $separator = ':';
660
        if (mb_strpos($facetWithValue, '%3A') !== false) {
661
            $separator = '%3A';
662
        }
663
664
        return $separator;
665
    }
666
667
    /**
668
     * Check if given facet value combination contains a separator
669
     *
670
     * @param string $facetWithValue
671
     * @return bool
672
     */
673
    public function containsFacetAndValueSeparator(string $facetWithValue): bool
674
    {
675
        if (mb_strpos($facetWithValue, ':') === false && mb_strpos($facetWithValue, '%3A') === false) {
676
            return false;
677
        }
678
679
        return true;
680
    }
681
682
    /**
683
     * Cleanup facet values (strip type if needed)
684
     *
685
     * @param array $facetValues
686
     * @return array
687
     */
688
    public function cleanupFacetValues(array $facetValues): array
689
    {
690
        $facetValuesCount = count($facetValues);
691
        for ($i = 0; $i < $facetValuesCount; $i++) {
692
            if (!$this->containsFacetAndValueSeparator((string)$facetValues[$i])) {
693
                continue;
694
            }
695
696
            $separator = $this->detectFacetAndValueSeparator((string)$facetValues[$i]);
697
            [$type, $value] = explode($separator, $facetValues[$i]);
698
699
            if ($this->isMappingArgument($type) || $this->isPathArgument($type)) {
700
                $facetValues[$i] = $value;
701
            }
702
        }
703
        return $facetValues;
704
    }
705
706
    /**
707
     * Builds a string out of multiple facet values
708
     *
709
     * @param array $facets
710
     * @return string
711
     */
712
    public function pathFacetsToString(array $facets): string
713
    {
714
        $facets = $this->cleanupFacetValues($facets);
715
        sort($facets);
716
        $facets = array_map([$this->urlFacetPathService, 'applyCharacterMap'], $facets);
717
        $facets = array_map([$this->urlFacetPathService, 'encodeSingleValue'], $facets);
718
        return implode($this->urlFacetPathService->getMultiValueSeparator(), $facets);
719
    }
720
721
    /**
722
     * Builds a string out of multiple facet values
723
     *
724
     * @param array $facets
725
     * @return string
726
     */
727
    public function facetsToString(array $facets): string
728
    {
729
        $facets = $this->cleanupFacetValues($facets);
730
        sort($facets);
731
        return implode($this->getDefaultMultiValueSeparator(), $facets);
732
    }
733
734
    /**
735
     * Builds a string out of multiple facet values
736
     *
737
     * This method is used in two different situation
738
     *  1. Middleware: Here the values should not be decoded
739
     *  2. Within the event listener CachedPathVariableModifier
740
     *
741
     * @param string $facets
742
     * @param bool $decode
743
     * @return array
744
     */
745
    public function pathFacetStringToArray(string $facets, bool $decode = true): array
746
    {
747
        $facetString = $this->urlFacetPathService->applyCharacterMap($facets);
748
        $facets = explode($this->urlFacetPathService->getMultiValueSeparator(), $facetString);
749
        if (!$decode) {
750
            return $facets;
751
        }
752
        return array_map([$this->urlFacetPathService, 'decodeSingleValue'], $facets);
753
    }
754
755
    /**
756
     * Returns the multi value separator
757
     * @return string
758
     */
759
    public function getDefaultMultiValueSeparator(): string
760
    {
761
        return $this->settings['multiValueSeparator'] ?? ',';
762
    }
763
764
    /**
765
     * Find a enhancer configuration by a given page id
766
     *
767
     * @param int $pageUid
768
     * @return array
769
     */
770 35
    public function fetchEnhancerByPageUid(int $pageUid): array
771
    {
772 35
        $site = $this->findSiteByUid($pageUid);
773 35
        if ($site instanceof NullSite) {
774
            return [];
775
        }
776
777 35
        return $this->fetchEnhancerInSiteConfigurationByPageUid(
778 35
            $site,
779
            $pageUid
780
        );
781
    }
782
783
    /**
784
     * Returns the route enhancer configuration by given site and page uid
785
     *
786
     * @param Site $site
787
     * @param int $pageUid
788
     * @return array
789
     */
790 35
    public function fetchEnhancerInSiteConfigurationByPageUid(Site $site, int $pageUid): array
791
    {
792 35
        $configuration = $site->getConfiguration();
793 35
        if (empty($configuration['routeEnhancers']) || !is_array($configuration['routeEnhancers'])) {
794 35
            return [];
795
        }
796
        $result = [];
797
        foreach ($configuration['routeEnhancers'] as $routing => $settings) {
798
            // Not the page we are looking for
799
            if (isset($settings['limitToPages']) &&
800
                is_array($settings['limitToPages']) &&
801
                !in_array($pageUid, $settings['limitToPages'])) {
802
                continue;
803
            }
804
805
            if (empty($settings) || !isset($settings['type']) ||
806
                !$this->isRouteEnhancerForSolr((string)$settings['type'])
807
            ) {
808
                continue;
809
            }
810
            $result[] = $settings;
811
        }
812
813
        return $result;
814
    }
815
816
    /**
817
     * Add heading slash to given slug
818
     *
819
     * @param string $slug
820
     * @return string
821
     */
822
    public function cleanupHeadingSlash(string $slug): string
823
    {
824
        if (mb_substr($slug, 0, 1) !== '/') {
825
            return '/' . $slug;
826
        } else if (mb_substr($slug, 0, 2) === '//') {
827
            return mb_substr($slug, 1, mb_strlen($slug) - 1);
828
        }
829
830
        return $slug;
831
    }
832
833
    /**
834
     * Add heading slash to given slug
835
     *
836
     * @param string $slug
837
     * @return string
838
     */
839
    public function addHeadingSlash(string $slug): string
840
    {
841
        if (mb_substr($slug, 0, 1) === '/') {
842
            return $slug;
843
        }
844
845
        return '/' . $slug;
846
    }
847
848
    /**
849
     * Remove heading slash from given slug
850
     *
851
     * @param string $slug
852
     * @return string
853
     */
854
    public function removeHeadingSlash(string $slug): string
855
    {
856
        if (mb_substr($slug, 0, 1) !== '/') {
857
            return $slug;
858
        }
859
860
        return mb_substr($slug, 1, mb_strlen($slug) - 1);
861
    }
862
863
    /**
864
     * Retrieve the site by given UID
865
     *
866
     * @param int $pageUid
867
     * @return SiteInterface
868
     */
869 35
    public function findSiteByUid(int $pageUid): SiteInterface
870
    {
871
        try {
872 35
            $site = $this->getSiteFinder()
873 35
                ->getSiteByPageId($pageUid);
874 35
            return $site;
875
        } catch (SiteNotFoundException $exception) {
876
            return new NullSite();
877
        }
878
    }
879
880
    /**
881
     * @param Site $site
882
     * @return PageSlugCandidateProvider
883
     */
884
    public function getSlugCandidateProvider(Site $site): PageSlugCandidateProvider
885
    {
886
        $context = GeneralUtility::makeInstance(Context::class);
887
        return GeneralUtility::makeInstance(
888
            PageSlugCandidateProvider::class,
889
            $context,
890
            $site,
891
            null
892
        );
893
    }
894
895
    /**
896
     * Convert the base string into a URI object
897
     *
898
     * @param string $base
899
     * @return UriInterface|null
900
     */
901
    public function convertStringIntoUri(string $base): ?UriInterface
902
    {
903
        try {
904
            /* @var Uri $uri */
905
            $uri = GeneralUtility::makeInstance(
906
                Uri::class,
907
                $base
908
            );
909
910
            return $uri;
911
        } catch (\InvalidArgumentException $argumentException) {
912
            return null;
913
        }
914
    }
915
916
    /**
917
     * In order to search for a path, a possible language prefix need to remove
918
     *
919
     * @param SiteLanguage $language
920
     * @param string $path
921
     * @return string
922
     */
923
    public function stripLanguagePrefixFromPath(SiteLanguage $language, string $path): string
924
    {
925
        if ($language->getBase()->getPath() === '/') {
926
            return $path;
927
        }
928
929
        $pathLength = mb_strlen($language->getBase()->getPath());
930
931
        $path = mb_substr($path, $pathLength, mb_strlen($path) - $pathLength);
932
        if (mb_substr($path, 0, 1) !== '/') {
933
            $path = '/' . $path;
934
        }
935
936
        return $path;
937
    }
938
939
    /**
940
     * Enrich the current query Params with data from path information
941
     *
942
     * @param ServerRequestInterface $request
943
     * @param array $arguments
944
     * @param array $parameters
945
     * @return ServerRequestInterface
946
     */
947
    public function addPathArgumentsToQuery(
948
        ServerRequestInterface $request,
949
        array $arguments,
950
        array $parameters
951
    ): ServerRequestInterface {
952
        $queryParams = $request->getQueryParams();
953
        foreach ($arguments as $fieldName => $queryPath) {
954
            // Skip if there is no parameter
955
            if (!isset($parameters[$fieldName])) {
956
                continue;
957
            }
958
            $pathElements = explode('/', $queryPath);
959
960
            if (!empty($this->pluginNamespace)) {
961
                array_unshift($pathElements, $this->pluginNamespace);
962
            }
963
964
            $queryParams = $this->processUriPathArgument(
965
                $queryParams,
966
                $fieldName,
967
                $parameters,
968
                $pathElements
969
            );
970
        }
971
972
        return $request->withQueryParams($queryParams);
973
    }
974
975
    /**
976
     * Check if given argument is a mapping argument
977
     *
978
     * @param string $facetName
979
     * @return bool
980
     */
981
    public function isMappingArgument(string $facetName): bool
982
    {
983
        $map = $this->getQueryParameterMap();
984
        if (isset($map[$facetName]) && $this->shouldMaskQueryParameter()) {
985
            return true;
986
        }
987
988
        return false;
989
    }
990
991
    /**
992
     * Check if given facet type is an path argument
993
     *
994
     * @param string $facetName
995
     * @return bool
996
     */
997
    public function isPathArgument(string $facetName): bool
998
    {
999
        return isset($this->pathArguments[$facetName]);
1000
    }
1001
1002
    /**
1003
     * @param string $variable
1004
     * @return string
1005
     */
1006
    public function reviewVariable(string $variable): string
1007
    {
1008
        if (!$this->containsFacetAndValueSeparator((string)$variable)) {
1009
            return $variable;
1010
        }
1011
1012
        $separator = $this->detectFacetAndValueSeparator((string)$variable);
1013
        [$type, $value] = explode($separator, $variable, 2);
1014
1015
        return $this->isMappingArgument($type) ? $value : $variable;
1016
    }
1017
1018
    /**
1019
     * Remove type prefix from filter
1020
     *
1021
     * @param array $variables
1022
     * @return array
1023
     */
1024
    public function reviseFilterVariables(array $variables): array
1025
    {
1026
        $newVariables = [];
1027
        foreach ($variables as $key => $value) {
1028
            $matches = [];
1029
            if (!preg_match('/###' . $this->getPluginNamespace() . ':filter:\d+:(.+?)###/', $key, $matches)) {
1030
                $newVariables[$key] = $value;
1031
                continue;
1032
            }
1033
            if (!$this->isMappingArgument($matches[1]) && !$this->isPathArgument($matches[1])) {
1034
                $newVariables[$key] = $value;
1035
                continue;
1036
            }
1037
            $separator = $this->detectFacetAndValueSeparator((string)$value);
1038
            $parts = explode($separator, $value);
1039
1040
            do {
1041
                if ($parts[0] === $matches[1]) {
1042
                    array_shift($parts);
1043
                }
1044
            } while ($parts[0] === $matches[1]);
1045
1046
            $newVariables[$key] = implode($separator, $parts);
1047
        }
1048
1049
        return $newVariables;
1050
    }
1051
1052
    /**
1053
     * Converts path segment information into query parameters
1054
     *
1055
     * Example:
1056
     * /products/household
1057
     *
1058
     * tx_solr:
1059
     *      filter:
1060
     *          - type:household
1061
     *
1062
     * @param array $queryParams
1063
     * @param string $fieldName
1064
     * @param array $parameters
1065
     * @param array $pathElements
1066
     * @return array
1067
     */
1068
    protected function processUriPathArgument(
1069
        array $queryParams,
1070
        string $fieldName,
1071
        array $parameters,
1072
        array $pathElements
1073
    ): array {
1074
        $queryKey = array_shift($pathElements);
1075
        $queryKey = (string)$queryKey;
1076
1077
        $tmpQueryKey = $queryKey;
1078
        if (strpos($queryKey, '-') !== false) {
1079
            [$tmpQueryKey, $filterName] = explode('-', $tmpQueryKey, 2);
1080
        }
1081
        if (!isset($queryParams[$tmpQueryKey]) || $queryParams[$tmpQueryKey] === null) {
1082
            $queryParams[$tmpQueryKey] = [];
1083
        }
1084
1085
        if (strpos($queryKey, '-') !== false) {
1086
            [$queryKey, $filterName] = explode('-', $queryKey, 2);
1087
            // explode multiple values
1088
            $values = $this->pathFacetStringToArray($parameters[$fieldName], false);
1089
            sort($values);
1090
1091
            // @TODO: Support URL data bag
1092
            foreach ($values as $value) {
1093
                $value = $this->urlFacetPathService->applyCharacterMap($value);
1094
                $queryParams[$queryKey][] = $filterName . ':' . $value;
1095
            }
1096
        } else {
1097
            $queryParams[$queryKey] = $this->processUriPathArgument(
1098
                $queryParams[$queryKey],
1099
                $fieldName,
1100
                $parameters,
1101
                $pathElements
1102
            );
1103
        }
1104
1105
        return $queryParams;
1106
    }
1107
1108
    /**
1109
     * Return site matcher
1110
     *
1111
     * @return SiteMatcher
1112
     */
1113
    public function getSiteMatcher(): SiteMatcher
1114
    {
1115
        return GeneralUtility::makeInstance(SiteMatcher::class, $this->getSiteFinder());
1116
    }
1117
1118
    /**
1119
     * Returns the site finder
1120
     *
1121
     * @return SiteFinder|null
1122
     */
1123 35
    protected function getSiteFinder(): ?SiteFinder
1124
    {
1125 35
        return GeneralUtility::makeInstance(SiteFinder::class);
1126
    }
1127
}
1128