Passed
Pull Request — main (#3378)
by Mario
50:21 queued 18:39
created

RoutingService::getPluginNamespace()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 1
dl 0
loc 3
ccs 0
cts 2
cp 0
rs 10
c 2
b 0
f 0
cc 1
nc 1
nop 0
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of the TYPO3 CMS project.
7
 *
8
 * It is free software; you can redistribute it and/or modify it under
9
 * the terms of the GNU General Public License, either version 2
10
 * of the License, or any later version.
11
 *
12
 * For the full copyright and license information, please read the
13
 * LICENSE.txt file that was distributed with this source code.
14
 *
15
 * The TYPO3 project - inspiring people to share!
16
 */
17
18
namespace ApacheSolrForTypo3\Solr\Routing;
19
20
use ApacheSolrForTypo3\Solr\Routing\Enhancer\SolrRouteEnhancerInterface;
21
use InvalidArgumentException;
22
use Psr\Http\Message\ServerRequestInterface;
23
use Psr\Http\Message\UriInterface;
24
use Psr\Log\LoggerAwareInterface;
25
use Psr\Log\LoggerAwareTrait;
26
use TYPO3\CMS\Core\Context\Context;
27
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
28
use TYPO3\CMS\Core\Http\Uri;
29
use TYPO3\CMS\Core\Routing\PageSlugCandidateProvider;
30
use TYPO3\CMS\Core\Routing\SiteMatcher;
31
use TYPO3\CMS\Core\Site\Entity\NullSite;
32
use TYPO3\CMS\Core\Site\Entity\Site;
33
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
34
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
35
use TYPO3\CMS\Core\Site\SiteFinder;
36
use TYPO3\CMS\Core\Utility\GeneralUtility;
37
38
/**
39
 * This service class bundles method required to process and manipulate routes.
40
 *
41
 * @author Lars Tode <[email protected]>
42
 */
43
class RoutingService implements LoggerAwareInterface
44
{
45
    use LoggerAwareTrait;
46
47
    /**
48
     * Default plugin namespace
49
     */
50
    const PLUGIN_NAMESPACE = 'tx_solr';
51
52
    /**
53
     * Settings from routing configuration
54
     *
55
     * @var array
56
     */
57
    protected array $settings = [];
58
59
    /**
60
     * List of filter that are placed as path arguments
61
     *
62
     * @var array
63
     */
64
    protected array $pathArguments = [];
65
66
    /**
67
     * Plugin/extension namespace
68
     *
69
     * @var string
70
     */
71
    protected string $pluginNamespace = 'tx_solr';
72
73
    /**
74
     * List of TYPO3 core parameters, that we should ignore
75
     *
76
     * @see \TYPO3\CMS\Frontend\Page\CacheHashCalculator::isCoreParameter()
77
     * @var string[]
78
     */
79
    protected array $coreParameters = [
80
        'no_cache',
81
        'cHash',
82
        'id',
83
        'MP',
84
        'type',
85
    ];
86
87
    /**
88
     * @var UrlFacetService|null
89
     */
90
    protected ?UrlFacetService $urlFacetPathService = null;
91
92
    /**
93
     * @var UrlFacetService|null
94
     */
95
    protected ?UrlFacetService $urlFacetQueryService = null;
96
97
    /**
98
     * RoutingService constructor.
99
     *
100
     * @param array $settings
101
     * @param string $pluginNamespace
102
     */
103 26
    public function __construct(array $settings = [], string $pluginNamespace = self::PLUGIN_NAMESPACE)
104
    {
105 26
        $this->settings = $settings;
106 26
        $this->pluginNamespace = $pluginNamespace;
107 26
        if (empty($this->pluginNamespace)) {
108
            $this->pluginNamespace = self::PLUGIN_NAMESPACE;
109
        }
110 26
        $this->initUrlFacetService();
111
    }
112
113
    /**
114
     * Creates a clone of the current service and replace the settings inside
115
     *
116
     * @param array $settings
117
     * @return RoutingService
118
     */
119
    public function withSettings(array $settings): RoutingService
120
    {
121
        $service = clone $this;
122
        $service->settings = $settings;
123
        $service->initUrlFacetService();
124
        return $service;
125
    }
126
127
    /**
128
     * Creates a clone of the current service and replace the settings inside
129
     *
130
     * @param array $pathArguments
131
     * @return RoutingService
132
     */
133
    public function withPathArguments(array $pathArguments): RoutingService
134
    {
135
        $service = clone $this;
136
        $service->pathArguments = $pathArguments;
137
        $service->initUrlFacetService();
138
        return $service;
139
    }
140
141
    /**
142
     * Load configuration from routing configuration
143
     *
144
     * @param array $routingConfiguration
145
     * @return $this
146
     */
147
    public function fromRoutingConfiguration(array $routingConfiguration): RoutingService
148
    {
149
        if (empty($routingConfiguration) ||
150
            empty($routingConfiguration['type']) ||
151
            !$this->isRouteEnhancerForSolr((string)$routingConfiguration['type'])) {
152
            return $this;
153
        }
154
155
        if (isset($routingConfiguration['solr'])) {
156
            $this->settings = $routingConfiguration['solr'];
157
            $this->initUrlFacetService();
158
        }
159
160
        if (isset($routingConfiguration['_arguments'])) {
161
            $this->pathArguments = $routingConfiguration['_arguments'];
162
        }
163
164
        return $this;
165
    }
166
167
    /**
168
     * Reset the routing service
169
     *
170
     * @return $this
171
     */
172 24
    public function reset(): RoutingService
173
    {
174 24
        $this->settings = [];
175 24
        $this->pathArguments = [];
176 24
        $this->pluginNamespace = self::PLUGIN_NAMESPACE;
177 24
        return $this;
178
    }
179
180
    /**
181
     * Initialize url facet services for different types
182
     *
183
     * @return $this
184
     */
185 26
    protected function initUrlFacetService(): RoutingService
186
    {
187 26
        $this->urlFacetPathService = new UrlFacetService('path', $this->settings);
188 26
        $this->urlFacetQueryService = new UrlFacetService('query', $this->settings);
189
190 26
        return $this;
191
    }
192
193
    /**
194
     * @return UrlFacetService
195
     */
196
    public function getUrlFacetPathService(): UrlFacetService
197
    {
198
        return $this->urlFacetPathService;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->urlFacetPathService could return the type null which is incompatible with the type-hinted return ApacheSolrForTypo3\Solr\Routing\UrlFacetService. Consider adding an additional type-check to rule them out.
Loading history...
199
    }
200
201
    /**
202
     * @return UrlFacetService
203
     */
204
    public function getUrlFacetQueryService(): UrlFacetService
205
    {
206
        return $this->urlFacetQueryService;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->urlFacetQueryService could return the type null which is incompatible with the type-hinted return ApacheSolrForTypo3\Solr\Routing\UrlFacetService. Consider adding an additional type-check to rule them out.
Loading history...
207
    }
208
209
    /**
210
     * Test if the given parameter is a Core parameter
211
     *
212
     * @see \TYPO3\CMS\Frontend\Page\CacheHashCalculator::isCoreParameter
213
     * @param string $parameterName
214
     * @return bool
215
     */
216
    public function isCoreParameter(string $parameterName): bool
217
    {
218
        return in_array($parameterName, $this->coreParameters);
219
    }
220
221
    /**
222
     * This returns the plugin namespace
223
     * @see https://docs.typo3.org/p/apache-solr-for-typo3/solr/main/en-us/Configuration/Reference/TxSolrView.html#pluginnamespace
224
     *
225
     * @return string
226
     */
227
    public function getPluginNamespace(): string
228
    {
229
        return $this->pluginNamespace;
230
    }
231
232
    /**
233
     * Determine if an enhancer is in use for Solr
234
     *
235
     * @param string $enhancerName
236
     * @return bool
237
     */
238
    public function isRouteEnhancerForSolr(string $enhancerName): bool
239
    {
240
        if (empty($enhancerName)) {
241
            return false;
242
        }
243
244
        if (!isset($GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['enhancers'][$enhancerName])) {
245
            return false;
246
        }
247
        $className = $GLOBALS['TYPO3_CONF_VARS']['SYS']['routing']['enhancers'][$enhancerName];
248
249
        if (!class_exists($className)) {
250
            return false;
251
        }
252
253
        $interfaces = class_implements($className);
254
255
        return in_array(SolrRouteEnhancerInterface::class, $interfaces);
256
    }
257
258
    /**
259
     * Masks Solr filter inside the query parameters
260
     *
261
     * @param string $uriPath
262
     * @return string
263
     */
264
    public function finalizePathQuery(string $uriPath): string
265
    {
266
        $pathSegments = explode('/', $uriPath);
267
        $query = array_pop($pathSegments);
268
        $queryValues = explode($this->urlFacetPathService->getMultiValueSeparator(), $query);
0 ignored issues
show
Bug introduced by
The method getMultiValueSeparator() 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

268
        $queryValues = explode($this->urlFacetPathService->/** @scrutinizer ignore-call */ getMultiValueSeparator(), $query);

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...
269
        $queryValues = array_map([$this->urlFacetPathService, 'decodeSingleValue'], $queryValues);
270
        /*
271
         * In some constellations the path query contains the facet type in front.
272
         * This leads to the result, that the query values could contain the same facet value multiple times.
273
         *
274
         * In order to avoid this behaviour, the query values need to be checked and clean up.
275
         * 1. Remove possible prefix information
276
         * 2. Apply character replacements
277
         * 3. Filter duplicate values
278
         */
279
        $queryValuesCount = count($queryValues);
280
        for ($i = 0; $i < $queryValuesCount; $i++) {
281
            $queryValues[$i] = urldecode($queryValues[$i]);
282
            if ($this->containsFacetAndValueSeparator($queryValues[$i])) {
283
                [$facetName, $facetValue] = explode(
284
                    $this->detectFacetAndValueSeparator($queryValues[$i]),
285
                    $queryValues[$i],
286
                    2
287
                );
288
289
                if ($this->isPathArgument((string)$facetName)) {
290
                    $queryValues[$i] = $facetValue;
291
                }
292
            }
293
            $queryValues[$i] = $this->urlFacetPathService->applyCharacterMap($queryValues[$i]);
294
        }
295
296
        $queryValues = array_unique($queryValues);
297
        $queryValues = array_map([$this->urlFacetPathService, 'encodeSingleValue'], $queryValues);
298
        sort($queryValues);
299
        $pathSegments[] = implode(
300
            $this->urlFacetPathService->getMultiValueSeparator(),
301
            $queryValues
302
        );
303
        return implode('/', $pathSegments);
304
    }
305
306
    /**
307
     * This method checks if the query parameter should be masked.
308
     *
309
     * @return bool
310
     */
311
    public function shouldMaskQueryParameter(): bool
312
    {
313
        if (!isset($this->settings['query']['mask']) ||
314
            !(bool)$this->settings['query']['mask']) {
315
            return false;
316
        }
317
318
        $targetFields = $this->getQueryParameterMap();
319
320
        return !empty($targetFields);
321
    }
322
323
    /**
324
     * Masks Solr filter inside the query parameters
325
     *
326
     * @param array $queryParams
327
     * @return array
328
     */
329 1
    public function maskQueryParameters(array $queryParams): array
330
    {
331 1
        if (!$this->shouldMaskQueryParameter()) {
332
            return $queryParams;
333
        }
334
335 1
        if (!isset($queryParams[$this->getPluginNamespace()])) {
336
            $this->logger
337
                ->/** @scrutinizer ignore-call */
338
                error('Mask error: Query parameters has no entry for namespace ' . $this->getPluginNamespace());
339
            return $queryParams;
340
        }
341
342 1
        if (!isset($queryParams[$this->getPluginNamespace()]['filter']) ||
343 1
            empty($queryParams[$this->getPluginNamespace()]['filter'])) {
344
            $this->logger
345
                ->/** @scrutinizer ignore-call */
346
                info('Mask info: Query parameters has no filter in namespace ' . $this->getPluginNamespace());
347
            return $queryParams;
348
        }
349
350 1
        if (!is_array($queryParams[$this->getPluginNamespace()]['filter'])) {
351
            $this->logger
352
                ->/** @scrutinizer ignore-call */
353
                warning('Mask info: Filter within the Query parameters is not an array');
354
            return $queryParams;
355
        }
356
357 1
        $queryParameterMap = $this->getQueryParameterMap();
358 1
        $newQueryParams = $queryParams;
359
360 1
        $newFilterArray = [];
361 1
        foreach ($newQueryParams[$this->getPluginNamespace()]['filter'] as $queryParamValue) {
362 1
            $defaultSeparator = $this->detectFacetAndValueSeparator((string)$queryParamValue);
363 1
            [$facetName, $facetValue] = explode($defaultSeparator, $queryParamValue, 2);
364 1
            $keep = false;
365 1
            if (isset($queryParameterMap[$facetName]) &&
366 1
                isset($newQueryParams[$queryParameterMap[$facetName]])) {
367
                $this->logger->/** @scrutinizer ignore-call */error(
368
                    'Mask error: Facet "' . $facetName . '" as "' . $queryParameterMap[$facetName] .
369
                    '" already in query!'
370
                );
371
                $keep = true;
372
            }
373 1
            if (!isset($queryParameterMap[$facetName]) || $keep) {
374
                $newFilterArray[] = $queryParamValue;
375
                continue;
376
            }
377
378 1
            $newQueryParams[$queryParameterMap[$facetName]] = $facetValue;
379
        }
380
381 1
        $newQueryParams[$this->getPluginNamespace()]['filter'] = $newFilterArray;
382
383 1
        return $this->cleanUpQueryParameters($newQueryParams);
384
    }
385
386
    /**
387
     * Unmask incoming parameters if needed
388
     *
389
     * @param array $queryParams
390
     * @return array
391
     */
392
    public function unmaskQueryParameters(array $queryParams): array
393
    {
394
        if (!$this->shouldMaskQueryParameter()) {
395
            return $queryParams;
396
        }
397
398
        /*
399
         * The array $queryParameterMap contains the mapping of
400
         * facet name to new url name. In order to unmask we need to switch key and values.
401
         */
402
        $queryParameterMap = $this->getQueryParameterMap();
403
        $queryParameterMapSwitched = [];
404
        foreach ($queryParameterMap as $value => $key) {
405
            $queryParameterMapSwitched[$key] = $value;
406
        }
407
408
        $newQueryParams = [];
409
        foreach ($queryParams as $queryParamName => $queryParamValue) {
410
            // A merge is needed!
411
            if (!isset($queryParameterMapSwitched[$queryParamName])) {
412
                if (isset($newQueryParams[$queryParamName])) {
413
                    $newQueryParams[$queryParamName] = array_merge_recursive(
414
                        $newQueryParams[$queryParamName],
415
                        $queryParamValue
416
                    );
417
                } else {
418
                    $newQueryParams[$queryParamName] = $queryParamValue;
419
                }
420
                continue;
421
            }
422
            if (!isset($newQueryParams[$this->getPluginNamespace()])) {
423
                $newQueryParams[$this->getPluginNamespace()] = [];
424
            }
425
            if (!isset($newQueryParams[$this->getPluginNamespace()]['filter'])) {
426
                $newQueryParams[$this->getPluginNamespace()]['filter'] = [];
427
            }
428
429
            $newQueryParams[$this->getPluginNamespace()]['filter'][] =
430
                $queryParameterMapSwitched[$queryParamName] . ':' . $queryParamValue;
431
        }
432
433
        return $this->cleanUpQueryParameters($newQueryParams);
434
    }
435
436
    /**
437
     * This method check if the query parameters should be touched or not.
438
     *
439
     * There are following requirements:
440
     * - Masking is activated and the mal is valid or
441
     * - Concat is activated
442
     *
443
     * @return bool
444
     */
445
    public function shouldConcatQueryParameters(): bool
446
    {
447
        /*
448
         * The concat will activate automatically if parameters should be masked.
449
         * This solution is less complex since not every mapping parameter needs to be tested
450
         */
451
        if ($this->shouldMaskQueryParameter()) {
452
            return true;
453
        }
454
455
        return isset($this->settings['query']['concat']) && (bool)$this->settings['query']['concat'] === true;
456
    }
457
458
    /**
459
     * Returns the query parameter map
460
     *
461
     * Note TYPO3 core query arguments removed from the configured map!
462
     *
463
     * @return array
464
     */
465
    public function getQueryParameterMap(): array
466
    {
467
        if (!isset($this->settings['query']['map']) ||
468
            !is_array($this->settings['query']['map']) ||
469
            empty($this->settings['query']['map'])) {
470
            return [];
471
        }
472
        // TODO: Test if there is more than one value!
473
        $self = $this;
474
        return array_filter(
475
            $this->settings['query']['map'],
476
            function ($value) use ($self) {
477
                return !$self->isCoreParameter($value);
478
            }
479
        );
480
    }
481
482
    /**
483
     * Group all filter values together and concat e
484
     * Note: this will just handle filter values
485
     *
486
     * IN:
487
     * tx_solr => [
488
     *   filter => [
489
     *      color:red
490
     *      product:candy
491
     *      color:blue
492
     *      taste:sour
493
     *   ]
494
     * ]
495
     *
496
     * OUT:
497
     * tx_solr => [
498
     *   filter => [
499
     *      color:blue,red
500
     *      product:candy
501
     *      taste:sour
502
     *   ]
503
     * ]
504
     * @param array $queryParams
505
     * @return array
506
     */
507 1
    public function concatQueryParameter(array $queryParams = []): array
508
    {
509 1
        if (!$this->shouldConcatQueryParameters()) {
510
            return $queryParams;
511
        }
512
513 1
        if (!isset($queryParams[$this->getPluginNamespace()])) {
514
            $this->logger
515
                ->error('Mask error: Query parameters has no entry for namespace ' . $this->getPluginNamespace());
0 ignored issues
show
Bug introduced by
The method error() 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

515
                ->/** @scrutinizer ignore-call */ 
516
                  error('Mask error: Query parameters has no entry for 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...
516
            return $queryParams;
517
        }
518
519 1
        if (!isset($queryParams[$this->getPluginNamespace()]['filter']) ||
520 1
            empty($queryParams[$this->getPluginNamespace()]['filter'])) {
521
            $this->logger
522
                ->info('Mask info: Query parameters has no filter in namespace ' . $this->getPluginNamespace());
523
            return $queryParams;
524
        }
525
526 1
        if (!is_array($queryParams[$this->getPluginNamespace()]['filter'])) {
527
            $this->logger
528
                ->/** @scrutinizer ignore-call */
529
                warning('Mask info: Filter within the Query parameters is not an array');
530
            return $queryParams;
531
        }
532
533 1
        $queryParams[$this->getPluginNamespace()]['filter'] =
534 1
            $this->concatFilterValues($queryParams[$this->getPluginNamespace()]['filter']);
535
536 1
        return $this->cleanUpQueryParameters($queryParams);
537
    }
538
539
    /**
540
     * This method expect a filter array that should be concat instead of the whole query
541
     *
542
     * @param array $filterArray
543
     * @return array
544
     */
545
    public function concatFilterValues(array $filterArray): array
546
    {
547
        if (empty($filterArray) || !$this->shouldConcatQueryParameters()) {
548
            return $filterArray;
549
        }
550
551
        $queryParameterMap = $this->getQueryParameterMap();
552
        $newFilterArray = [];
553
        $defaultSeparator = $this->detectFacetAndValueSeparator((string)$filterArray[0]);
554
        // Collect parameter names and rename parameter if required
555
        foreach ($filterArray as $set) {
556
            $separator = $this->detectFacetAndValueSeparator((string)$set);
557
            [$facetName, $facetValue] = explode($separator, $set, 2);
558
            if (isset($queryParameterMap[$facetName])) {
559
                $facetName = $queryParameterMap[$facetName];
560
            }
561
            if (!isset($newFilterArray[$facetName])) {
562
                $newFilterArray[$facetName] = [$facetValue];
563
            } else {
564
                $newFilterArray[$facetName][] = $facetValue;
565
            }
566
        }
567
568
        foreach ($newFilterArray as $facetName => $facetValues) {
569
            $newFilterArray[$facetName] = $facetName . $defaultSeparator . $this->queryParameterFacetsToString($facetValues);
570
        }
571
572
        return array_values($newFilterArray);
573
    }
574
575
    /**
576
     * Inflate given query parameters if configured
577
     * Note: this will just combine filter values
578
     *
579
     * IN:
580
     * tx_solr => [
581
     *   filter => [
582
     *      color:blue,red
583
     *      product:candy
584
     *      taste:sour
585
     *   ]
586
     * ]
587
     *
588
     * OUT:
589
     * tx_solr => [
590
     *   filter => [
591
     *      color:red
592
     *      product:candy
593
     *      color:blue
594
     *      taste:sour
595
     *   ]
596
     * ]
597
     *
598
     * @param array $queryParams
599
     * @return array
600
     */
601 1
    public function inflateQueryParameter(array $queryParams = []): array
602
    {
603 1
        if (!$this->shouldConcatQueryParameters()) {
604
            return $queryParams;
605
        }
606
607 1
        if (!isset($queryParams[$this->getPluginNamespace()])) {
608
            $queryParams[$this->getPluginNamespace()] = [];
609
        }
610
611 1
        if (!isset($queryParams[$this->getPluginNamespace()]['filter']) ||
612 1
            is_null($queryParams[$this->getPluginNamespace()]['filter'])) {
613
            $queryParams[$this->getPluginNamespace()]['filter'] = [];
614
        }
615
616 1
        if (!is_array($queryParams[$this->getPluginNamespace()]['filter'])) {
617
            $this->logger
618
                ->/** @scrutinizer ignore-call */
619
                warning('Inflate query: Expected filter to be an array. Replace it with an array structure!');
620
            $queryParams[$this->getPluginNamespace()]['filter'] = [];
621
        }
622
623 1
        $newQueryParams = [];
624 1
        foreach ($queryParams[$this->getPluginNamespace()]['filter'] as $set) {
625 1
            $separator = $this->detectFacetAndValueSeparator((string)$set);
626 1
            [$facetName, $facetValuesString] = explode($separator, $set, 2);
627 1
            if ($facetValuesString == null) {
628
                continue;
629
            }
630 1
            $facetValues = explode($this->urlFacetQueryService->getMultiValueSeparator(), $facetValuesString);
0 ignored issues
show
Bug introduced by
The method getMultiValueSeparator() 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

630
            $facetValues = explode($this->urlFacetQueryService->/** @scrutinizer ignore-call */ getMultiValueSeparator(), $facetValuesString);

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...
631
632
            /**
633
             * A facet value could contain the multi value separator. This value is masked in order to
634
             * avoid problems during separation of the values (line above).
635
             *
636
             * After splitting the values, the character inside the value need to be restored
637
             *
638
             * @see RoutingService::queryParameterFacetsToString
639
             */
640 1
            $facetValues = array_map([$this->urlFacetQueryService, 'decodeSingleValue'], $facetValues);
641
642 1
            foreach ($facetValues as $facetValue) {
643 1
                $newQueryParams[] = $facetName . $separator . $facetValue;
644
            }
645
        }
646 1
        $queryParams[$this->getPluginNamespace()]['filter'] = array_values($newQueryParams);
647
648 1
        return $this->cleanUpQueryParameters($queryParams);
649
    }
650
651
    /**
652
     * Cleanup the query parameters, to avoid empty solr arguments
653
     *
654
     * @param array $queryParams
655
     * @return array
656
     */
657
    public function cleanUpQueryParameters(array $queryParams): array
658
    {
659
        if (empty($queryParams[$this->getPluginNamespace()]['filter'])) {
660
            unset($queryParams[$this->getPluginNamespace()]['filter']);
661
        }
662
663
        if (empty($queryParams[$this->getPluginNamespace()])) {
664
            unset($queryParams[$this->getPluginNamespace()]);
665
        }
666
        return $queryParams;
667
    }
668
669
    /**
670
     * Builds a string out of multiple facet values
671
     *
672
     * A facet value could contain the multi value separator. This value has to be masked in order to
673
     * avoid problems during separation of the values later.
674
     *
675
     * This mask has to be applied before contact the values
676
     *
677
     * @param array $facets
678
     * @return string
679
     */
680
    public function queryParameterFacetsToString(array $facets): string
681
    {
682
        $facets = array_map([$this->urlFacetQueryService, 'encodeSingleValue'], $facets);
683
        sort($facets);
684
        return implode($this->urlFacetQueryService->getMultiValueSeparator(), $facets);
685
    }
686
687
    /**
688
     * Returns the string which separates the facet from the value
689
     *
690
     * @param string $facetWithValue
691
     * @return string
692
     */
693
    public function detectFacetAndValueSeparator(string $facetWithValue): string
694
    {
695
        $separator = ':';
696
        if (mb_strpos($facetWithValue, '%3A') !== false) {
697
            $separator = '%3A';
698
        }
699
700
        return $separator;
701
    }
702
703
    /**
704
     * Check if given facet value combination contains a separator
705
     *
706
     * @param string $facetWithValue
707
     * @return bool
708
     */
709
    public function containsFacetAndValueSeparator(string $facetWithValue): bool
710
    {
711
        if (mb_strpos($facetWithValue, ':') === false && mb_strpos($facetWithValue, '%3A') === false) {
712
            return false;
713
        }
714
715
        return true;
716
    }
717
718
    /**
719
     * Cleanup facet values (strip type if needed)
720
     *
721
     * @param array $facetValues
722
     * @return array
723
     */
724
    public function cleanupFacetValues(array $facetValues): array
725
    {
726
        $facetValuesCount = count($facetValues);
727
        for ($i = 0; $i < $facetValuesCount; $i++) {
728
            if (!$this->containsFacetAndValueSeparator((string)$facetValues[$i])) {
729
                continue;
730
            }
731
732
            $separator = $this->detectFacetAndValueSeparator((string)$facetValues[$i]);
733
            [$type, $value] = explode($separator, $facetValues[$i]);
734
735
            if ($this->isMappingArgument($type) || $this->isPathArgument($type)) {
736
                $facetValues[$i] = $value;
737
            }
738
        }
739
        return $facetValues;
740
    }
741
742
    /**
743
     * Builds a string out of multiple facet values
744
     *
745
     * @param array $facets
746
     * @return string
747
     */
748
    public function pathFacetsToString(array $facets): string
749
    {
750
        $facets = $this->cleanupFacetValues($facets);
751
        sort($facets);
752
        $facets = array_map([$this->urlFacetPathService, 'applyCharacterMap'], $facets);
753
        $facets = array_map([$this->urlFacetPathService, 'encodeSingleValue'], $facets);
754
        return implode($this->urlFacetPathService->getMultiValueSeparator(), $facets);
755
    }
756
757
    /**
758
     * Builds a string out of multiple facet values
759
     *
760
     * @param array $facets
761
     * @return string
762
     */
763 2
    public function facetsToString(array $facets): string
764
    {
765 2
        $facets = $this->cleanupFacetValues($facets);
766 2
        sort($facets);
767 2
        return implode($this->getDefaultMultiValueSeparator(), $facets);
768
    }
769
770
    /**
771
     * Builds a string out of multiple facet values
772
     *
773
     * This method is used in two different situation
774
     *  1. Middleware: Here the values should not be decoded
775
     *  2. Within the event listener CachedPathVariableModifier
776
     *
777
     * @param string $facets
778
     * @param bool $decode
779
     * @return array
780
     */
781
    public function pathFacetStringToArray(string $facets, bool $decode = true): array
782
    {
783
        $facetString = $this->urlFacetPathService->applyCharacterMap($facets);
784
        $facets = explode($this->urlFacetPathService->getMultiValueSeparator(), $facetString);
785
        if (!$decode) {
786
            return $facets;
787
        }
788
        return array_map([$this->urlFacetPathService, 'decodeSingleValue'], $facets);
789
    }
790
791
    /**
792
     * Returns the multi value separator
793
     * @return string
794
     */
795 2
    public function getDefaultMultiValueSeparator(): string
796
    {
797 2
        return $this->settings['multiValueSeparator'] ?? ',';
798
    }
799
800
    /**
801
     * Find an enhancer configuration by a given page id
802
     *
803
     * @param int $pageUid
804
     * @return array
805
     */
806 24
    public function fetchEnhancerByPageUid(int $pageUid): array
807
    {
808 24
        $site = $this->findSiteByUid($pageUid);
809 24
        if ($site instanceof NullSite) {
810
            return [];
811
        }
812
813
        /** @noinspection PhpParamsInspection */
814 24
        return $this->fetchEnhancerInSiteConfigurationByPageUid(
815
            $site,
816
            $pageUid
817
        );
818
    }
819
820
    /**
821
     * Returns the route enhancer configuration by given site and page uid
822
     *
823
     * @param Site $site
824
     * @param int $pageUid
825
     * @return array
826
     */
827 24
    public function fetchEnhancerInSiteConfigurationByPageUid(Site $site, int $pageUid): array
828
    {
829 24
        $configuration = $site->getConfiguration();
830 24
        if (empty($configuration['routeEnhancers']) || !is_array($configuration['routeEnhancers'])) {
831 24
            return [];
832
        }
833
        $result = [];
834
        foreach ($configuration['routeEnhancers'] as $settings) {
835
            // Not the page we are looking for
836
            if (isset($settings['limitToPages']) &&
837
                is_array($settings['limitToPages']) &&
838
                !in_array($pageUid, $settings['limitToPages'])) {
839
                continue;
840
            }
841
842
            if (empty($settings) || !isset($settings['type']) ||
843
                !$this->isRouteEnhancerForSolr((string)$settings['type'])
844
            ) {
845
                continue;
846
            }
847
            $result[] = $settings;
848
        }
849
850
        return $result;
851
    }
852
853
    /**
854
     * Add heading slash to given slug
855
     *
856
     * @param string $slug
857
     * @return string
858
     */
859
    public function cleanupHeadingSlash(string $slug): string
860
    {
861
        if (mb_substr($slug, 0, 1) !== '/') {
862
            return '/' . $slug;
863
        }
864
        if (mb_substr($slug, 0, 2) === '//') {
865
            return mb_substr($slug, 1, mb_strlen($slug) - 1);
866
        }
867
868
        return $slug;
869
    }
870
871
    /**
872
     * Add heading slash to given slug
873
     *
874
     * @param string $slug
875
     * @return string
876
     */
877
    public function addHeadingSlash(string $slug): string
878
    {
879
        if (mb_substr($slug, 0, 1) === '/') {
880
            return $slug;
881
        }
882
883
        return '/' . $slug;
884
    }
885
886
    /**
887
     * Remove heading slash from given slug
888
     *
889
     * @param string $slug
890
     * @return string
891
     */
892
    public function removeHeadingSlash(string $slug): string
893
    {
894
        if (mb_substr($slug, 0, 1) !== '/') {
895
            return $slug;
896
        }
897
898
        return mb_substr($slug, 1, mb_strlen($slug) - 1);
899
    }
900
901
    /**
902
     * Retrieve the site by given UID
903
     *
904
     * @param int $pageUid
905
     * @return SiteInterface
906
     */
907 24
    public function findSiteByUid(int $pageUid): SiteInterface
908
    {
909
        try {
910 24
            return $this->getSiteFinder()
911 24
                ->getSiteByPageId($pageUid);
912
        } catch (SiteNotFoundException $exception) {
913
            return new NullSite();
914
        }
915
    }
916
917
    /**
918
     * @param Site $site
919
     * @return PageSlugCandidateProvider
920
     */
921
    public function getSlugCandidateProvider(Site $site): PageSlugCandidateProvider
922
    {
923
        $context = GeneralUtility::makeInstance(Context::class);
924
        return GeneralUtility::makeInstance(
925
            PageSlugCandidateProvider::class,
926
            $context,
927
            $site,
928
            null
929
        );
930
    }
931
932
    /**
933
     * Convert the base string into a URI object
934
     *
935
     * @param string $base
936
     * @return UriInterface|null
937
     */
938 1
    public function convertStringIntoUri(string $base): ?UriInterface
939
    {
940
        try {
941
            /* @var Uri $uri */
942 1
            return GeneralUtility::makeInstance(
943
                Uri::class,
944
                $base
945
            );
946
        } catch (InvalidArgumentException $argumentException) {
947
            return null;
948
        }
949
    }
950
951
    /**
952
     * In order to search for a path, a possible language prefix need to remove
953
     *
954
     * @param SiteLanguage $language
955
     * @param string $path
956
     * @return string
957
     */
958
    public function stripLanguagePrefixFromPath(SiteLanguage $language, string $path): string
959
    {
960
        if ($language->getBase()->getPath() === '/') {
961
            return $path;
962
        }
963
964
        $pathLength = mb_strlen($language->getBase()->getPath());
965
966
        $path = mb_substr($path, $pathLength, mb_strlen($path) - $pathLength);
967
        if (mb_substr($path, 0, 1) !== '/') {
968
            $path = '/' . $path;
969
        }
970
971
        return $path;
972
    }
973
974
    /**
975
     * Enrich the current query Params with data from path information
976
     *
977
     * @param ServerRequestInterface $request
978
     * @param array $arguments
979
     * @param array $parameters
980
     * @return ServerRequestInterface
981
     */
982 3
    public function addPathArgumentsToQuery(
983
        ServerRequestInterface $request,
984
        array $arguments,
985
        array $parameters
986
    ): ServerRequestInterface {
987 3
        $queryParams = $request->getQueryParams();
988 3
        foreach ($arguments as $fieldName => $queryPath) {
989
            // Skip if there is no parameter
990 3
            if (!isset($parameters[$fieldName])) {
991
                continue;
992
            }
993 3
            $pathElements = explode('/', $queryPath);
994
995 3
            if (!empty($this->pluginNamespace)) {
996 3
                array_unshift($pathElements, $this->pluginNamespace);
997
            }
998
999 3
            $queryParams = $this->processUriPathArgument(
1000
                $queryParams,
1001
                $fieldName,
1002
                $parameters,
1003
                $pathElements
1004
            );
1005
        }
1006
1007 3
        return $request->withQueryParams($queryParams);
1008
    }
1009
1010
    /**
1011
     * Check if given argument is a mapping argument
1012
     *
1013
     * @param string $facetName
1014
     * @return bool
1015
     */
1016
    public function isMappingArgument(string $facetName): bool
1017
    {
1018
        $map = $this->getQueryParameterMap();
1019
        if (isset($map[$facetName]) && $this->shouldMaskQueryParameter()) {
1020
            return true;
1021
        }
1022
1023
        return false;
1024
    }
1025
1026
    /**
1027
     * Check if given facet type is a path argument
1028
     *
1029
     * @param string $facetName
1030
     * @return bool
1031
     */
1032
    public function isPathArgument(string $facetName): bool
1033
    {
1034
        return isset($this->pathArguments[$facetName]);
1035
    }
1036
1037
    /**
1038
     * @param string $variable
1039
     * @return string
1040
     */
1041
    public function reviewVariable(string $variable): string
1042
    {
1043
        if (!$this->containsFacetAndValueSeparator($variable)) {
1044
            return $variable;
1045
        }
1046
1047
        $separator = $this->detectFacetAndValueSeparator($variable);
1048
        [$type, $value] = explode($separator, $variable, 2);
1049
1050
        return $this->isMappingArgument($type) ? $value : $variable;
1051
    }
1052
1053
    /**
1054
     * Remove type prefix from filter
1055
     *
1056
     * @param array $variables
1057
     * @return array
1058
     */
1059
    public function reviseFilterVariables(array $variables): array
1060
    {
1061
        $newVariables = [];
1062
        foreach ($variables as $key => $value) {
1063
            $matches = [];
1064
            if (!preg_match('/###' . $this->getPluginNamespace() . ':filter:\d+:(.+?)###/', $key, $matches)) {
1065
                $newVariables[$key] = $value;
1066
                continue;
1067
            }
1068
            if (!$this->isMappingArgument($matches[1]) && !$this->isPathArgument($matches[1])) {
1069
                $newVariables[$key] = $value;
1070
                continue;
1071
            }
1072
            $separator = $this->detectFacetAndValueSeparator((string)$value);
1073
            $parts = explode($separator, $value);
1074
1075
            do {
1076
                if ($parts[0] === $matches[1]) {
1077
                    array_shift($parts);
1078
                }
1079
            } while ($parts[0] === $matches[1]);
1080
1081
            $newVariables[$key] = implode($separator, $parts);
1082
        }
1083
1084
        return $newVariables;
1085
    }
1086
1087
    /**
1088
     * Converts path segment information into query parameters
1089
     *
1090
     * Example:
1091
     * /products/household
1092
     *
1093
     * tx_solr:
1094
     *      filter:
1095
     *          - type:household
1096
     *
1097
     * @param array $queryParams
1098
     * @param string $fieldName
1099
     * @param array $parameters
1100
     * @param array $pathElements
1101
     * @return array
1102
     */
1103
    protected function processUriPathArgument(
1104
        array $queryParams,
1105
        string $fieldName,
1106
        array $parameters,
1107
        array $pathElements
1108
    ): array {
1109
        $queryKey = array_shift($pathElements);
1110
        $queryKey = (string)$queryKey;
1111
1112
        $tmpQueryKey = $queryKey;
1113
        if (strpos($queryKey, '-') !== false) {
1114
            [$tmpQueryKey, $filterName] = explode('-', $tmpQueryKey, 2);
1115
        }
1116
        if (!isset($queryParams[$tmpQueryKey])) {
1117
            $queryParams[$tmpQueryKey] = [];
1118
        }
1119
1120
        if (strpos($queryKey, '-') !== false) {
1121
            [$queryKey, $filterName] = explode('-', $queryKey, 2);
1122
            // explode multiple values
1123
            $values = $this->pathFacetStringToArray($parameters[$fieldName], false);
1124
            sort($values);
1125
1126
            // @TODO: Support URL data bag
1127
            foreach ($values as $value) {
1128
                $value = $this->urlFacetPathService->applyCharacterMap($value);
1129
                $queryParams[$queryKey][] = $filterName . ':' . $value;
1130
            }
1131
        } else {
1132
            $queryParams[$queryKey] = $this->processUriPathArgument(
1133
                $queryParams[$queryKey],
1134
                $fieldName,
1135
                $parameters,
1136
                $pathElements
1137
            );
1138
        }
1139
1140
        return $queryParams;
1141
    }
1142
1143
    /**
1144
     * Return site matcher
1145
     *
1146
     * @return SiteMatcher
1147
     */
1148
    public function getSiteMatcher(): SiteMatcher
1149
    {
1150
        return GeneralUtility::makeInstance(SiteMatcher::class, $this->getSiteFinder());
1151
    }
1152
1153
    /**
1154
     * Returns the site finder
1155
     *
1156
     * @return SiteFinder|null
1157
     */
1158 24
    protected function getSiteFinder(): ?SiteFinder
1159
    {
1160 24
        return GeneralUtility::makeInstance(SiteFinder::class);
1161
    }
1162
}
1163