Completed
Push — master ( 0346f6...23ff5c )
by
unknown
15:39
created

getPagesFromDatabaseForCandidates()   D

Complexity

Conditions 20
Paths 115

Size

Total Lines 127
Code Lines 75

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 20
eloc 75
nc 115
nop 3
dl 0
loc 127
rs 4.0416
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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 TYPO3\CMS\Core\Routing;
19
20
use Doctrine\DBAL\Connection;
21
use TYPO3\CMS\Core\Context\Context;
22
use TYPO3\CMS\Core\Database\ConnectionPool;
23
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
24
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendWorkspaceRestriction;
25
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
26
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
27
use TYPO3\CMS\Core\Routing\Enhancer\DecoratingEnhancerInterface;
28
use TYPO3\CMS\Core\Routing\Enhancer\EnhancerFactory;
29
use TYPO3\CMS\Core\Site\Entity\Site;
30
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
31
use TYPO3\CMS\Core\Site\SiteFinder;
32
use TYPO3\CMS\Core\Utility\GeneralUtility;
33
use TYPO3\CMS\Core\Utility\RootlineUtility;
34
35
/**
36
 * Provides possible pages (from the database) that _could_ match a certain URL path,
37
 * but also works for fetching the best "slug" value for multi-lingual pages with a specific language requested.
38
 *
39
 * @internal as this API might change and a possible interface is given at some point.
40
 */
41
class PageSlugCandidateProvider
42
{
43
    /**
44
     * @var Site
45
     */
46
    protected $site;
47
48
    /**
49
     * @var Context
50
     */
51
    protected $context;
52
53
    /**
54
     * @var EnhancerFactory
55
     */
56
    protected $enhancerFactory;
57
58
    public function __construct(Context $context, Site $site, ?EnhancerFactory $enhancerFactory)
59
    {
60
        $this->context = $context;
61
        $this->site = $site;
62
        $this->enhancerFactory = $enhancerFactory ?? GeneralUtility::makeInstance(EnhancerFactory::class);
63
    }
64
65
    /**
66
     * Fetches an array of possible URLs that match the current site + language (incl. fallbacks)
67
     *
68
     * @param string $urlPath
69
     * @param SiteLanguage $language
70
     * @return string[]
71
     */
72
    public function getCandidatesForPath(string $urlPath, SiteLanguage $language): array
73
    {
74
        $slugCandidates = $this->getCandidateSlugsFromRoutePath($urlPath ?: '/');
75
        $pageCandidates = [];
76
        $languages = [$language->getLanguageId()];
77
        if (!empty($language->getFallbackLanguageIds())) {
78
            $languages = array_merge($languages, $language->getFallbackLanguageIds());
79
        }
80
        // Iterate all defined languages in their configured order to get matching page candidates somewhere in the language fallback chain
81
        foreach ($languages as $languageId) {
82
            $pageCandidatesFromSlugsAndLanguage = $this->getPagesFromDatabaseForCandidates($slugCandidates, $languageId);
83
            // Determine whether fetched page candidates qualify for the request. The incoming URL is checked against all
84
            // pages found for the current URL and language.
85
            foreach ($pageCandidatesFromSlugsAndLanguage as $candidate) {
86
                $slugCandidate = '/' . trim($candidate['slug'], '/');
87
                if ($slugCandidate === '/' || strpos($urlPath, $slugCandidate) === 0) {
88
                    // The slug is a subpart of the requested URL, so it's a possible candidate
89
                    if ($urlPath === $slugCandidate) {
90
                        // The requested URL matches exactly the found slug. We can't find a better match,
91
                        // so use that page candidate and stop any further querying.
92
                        $pageCandidates = [$candidate];
93
                        break 2;
94
                    }
95
96
                    $pageCandidates[] = $candidate;
97
                }
98
            }
99
        }
100
        return $pageCandidates;
101
    }
102
103
    /**
104
     * Fetches the page without any language or other hidden/enable fields, but only takes
105
     * "deleted" and "workspace" into account, as all other things will be evaluated later.
106
     *
107
     * This is only needed for resolving the ACTUAL Page Id when index.php?id=13 was given
108
     *
109
     * Should be rebuilt to return the actual Page ID considering the online ID of the page.
110
     *
111
     * @param int $pageId
112
     * @return int|null
113
     */
114
    public function getRealPageIdForPageIdAsPossibleCandidate(int $pageId): ?int
115
    {
116
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
117
            ->getQueryBuilderForTable('pages');
118
        $queryBuilder
119
            ->getRestrictions()
120
            ->removeAll()
121
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
122
            ->add(GeneralUtility::makeInstance(FrontendWorkspaceRestriction::class));
123
124
        $statement = $queryBuilder
125
            ->select('uid', 'l10n_parent')
126
            ->from('pages')
127
            ->where(
128
                $queryBuilder->expr()->eq(
129
                    'uid',
130
                    $queryBuilder->createNamedParameter($pageId, \PDO::PARAM_INT)
131
                )
132
            )
133
            ->execute();
134
135
        $page = $statement->fetch();
136
        if (empty($page)) {
137
            return null;
138
        }
139
        return (int)($page['l10n_parent'] ?: $page['uid']);
140
    }
141
142
    /**
143
     * Gets all patterns that can be used to redecorate (undecorate) a
144
     * potential previously decorated route path.
145
     *
146
     * @return string regular expression pattern capable of redecorating
147
     */
148
    protected function getRoutePathRedecorationPattern(): string
149
    {
150
        $decoratingEnhancers = $this->getDecoratingEnhancers();
151
        if (empty($decoratingEnhancers)) {
152
            return '';
153
        }
154
        $redecorationPatterns = array_map(
155
            function (DecoratingEnhancerInterface $decorationEnhancers) {
156
                $pattern = $decorationEnhancers->getRoutePathRedecorationPattern();
157
                return '(?:' . $pattern . ')';
158
            },
159
            $decoratingEnhancers
160
        );
161
        return '(?P<decoration>' . implode('|', $redecorationPatterns) . ')';
162
    }
163
164
    /**
165
     * Resolves decorating enhancers without having aspects assigned. These
166
     * instances are used to pre-process URL path and MUST NOT be used for
167
     * actually resolving or generating URL parameters.
168
     *
169
     * @return DecoratingEnhancerInterface[]
170
     */
171
    protected function getDecoratingEnhancers(): array
172
    {
173
        $enhancers = [];
174
        foreach ($this->site->getConfiguration()['routeEnhancers'] ?? [] as $enhancerConfiguration) {
175
            $enhancerType = $enhancerConfiguration['type'] ?? '';
176
            $enhancer = $this->enhancerFactory->create($enhancerType, $enhancerConfiguration);
177
            if ($enhancer instanceof DecoratingEnhancerInterface) {
178
                $enhancers[] = $enhancer;
179
            }
180
        }
181
        return $enhancers;
182
    }
183
184
    /**
185
     * Check for records in the database which matches one of the slug candidates.
186
     *
187
     * @param array $slugCandidates
188
     * @param int $languageId
189
     * @param array $excludeUids when called recursively this is the mountpoint parameter of the original prefix
190
     * @return array[]|array
191
     * @throws SiteNotFoundException
192
     */
193
    protected function getPagesFromDatabaseForCandidates(array $slugCandidates, int $languageId, array $excludeUids = []): array
194
    {
195
        $searchLiveRecordsOnly = $this->context->getPropertyFromAspect('workspace', 'isLive');
196
        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
197
            ->getQueryBuilderForTable('pages');
198
        $queryBuilder
199
            ->getRestrictions()
200
            ->removeAll()
201
            ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
202
            ->add(GeneralUtility::makeInstance(FrontendWorkspaceRestriction::class, null, null, $searchLiveRecordsOnly));
203
204
        $statement = $queryBuilder
205
            ->select('uid', 'l10n_parent', 'pid', 'slug', 'mount_pid', 'mount_pid_ol', 't3ver_state', 'doktype')
206
            ->from('pages')
207
            ->where(
208
                $queryBuilder->expr()->eq(
209
                    'sys_language_uid',
210
                    $queryBuilder->createNamedParameter($languageId, \PDO::PARAM_INT)
211
                ),
212
                $queryBuilder->expr()->in(
213
                    'slug',
214
                    $queryBuilder->createNamedParameter(
215
                        $slugCandidates,
216
                        Connection::PARAM_STR_ARRAY
217
                    )
218
                )
219
            )
220
            // Exact match will be first, that's important
221
            ->orderBy('slug', 'desc')
222
            // Sort pages that are not MountPoint pages before mount points
223
            ->addOrderBy('mount_pid_ol', 'asc')
224
            ->addOrderBy('mount_pid', 'asc')
225
            ->execute();
226
227
        $pages = [];
228
        $siteFinder = GeneralUtility::makeInstance(SiteFinder::class);
229
        $pageRepository = GeneralUtility::makeInstance(PageRepository::class, $this->context);
230
        $isRecursiveCall = !empty($excludeUids);
231
232
        while ($row = $statement->fetch()) {
233
            $mountPageInformation = null;
234
            $pageRepository->fixVersioningPid('pages', $row);
235
            $pageIdInDefaultLanguage = (int)($languageId > 0 ? $row['l10n_parent'] : $row['uid']);
236
            // When this page was added before via recursion, this page should be skipped
237
            if (in_array($pageIdInDefaultLanguage, $excludeUids, true)) {
238
                continue;
239
            }
240
241
            try {
242
                $isOnSameSite = $siteFinder->getSiteByPageId($pageIdInDefaultLanguage)->getRootPageId() === $this->site->getRootPageId();
243
            } catch (SiteNotFoundException $e) {
244
                // Page is not in a site, so it's not considered
245
                $isOnSameSite = false;
246
            }
247
248
            // If a MountPoint is found on the current site, and it hasn't been added yet by some other iteration
249
            // (see below "findPageCandidatesOfMountPoint"), then let's resolve the MountPoint information now
250
            if (!$isOnSameSite && $isRecursiveCall) {
251
                // Not in the same site, and called recursive, should be skipped
252
                continue;
253
            }
254
            $mountPageInformation = $pageRepository->getMountPointInfo($pageIdInDefaultLanguage, $row);
255
256
            // Mount Point Pages which are not on the same site (when not called on the first level) should be skipped
257
            // As they just clutter up the queries.
258
            if (!$isOnSameSite && !$isRecursiveCall && $mountPageInformation) {
259
                continue;
260
            }
261
262
            $mountedPage = null;
263
            if ($mountPageInformation) {
264
                // Add the MPvar to the row, so it can be used later-on in the PageRouter / PageArguments
265
                $row['MPvar'] = $mountPageInformation['MPvar'];
266
                $mountedPage = $pageRepository->getPage_noCheck($mountPageInformation['mount_pid_rec']['uid']);
267
                // Ensure to fetch the slug in the translated page
268
                $mountedPage = $pageRepository->getPageOverlay($mountedPage, $languageId);
269
                // Mount wasn't connected properly, so it is skipped
270
                if (!$mountedPage) {
271
                    continue;
272
                }
273
                // If the page is a MountPoint which should be overlaid with the contents of the mounted page,
274
                // it must never be accessible directly, but only in the MountPoint context. Therefore we change
275
                // the current ID and slug.
276
                // This needs to happen before the regular case, as the $pageToAdd contains the MPvar information
277
                if (PageRepository::DOKTYPE_MOUNTPOINT === (int)$row['doktype'] && $row['mount_pid_ol']) {
278
                    // If the mounted page was already added from above, this should not be added again (to include
279
                    // the mount point parameter).
280
                    if (in_array((int)$mountedPage['uid'], $excludeUids, true)) {
281
                        continue;
282
                    }
283
                    $pageToAdd = $mountedPage;
284
                    // Make sure target page "/about-us" is replaced by "/global-site/about-us" so router works
285
                    $pageToAdd['MPvar'] = $mountPageInformation['MPvar'];
286
                    $pageToAdd['slug'] = $row['slug'];
287
                    $pages[] = $pageToAdd;
288
                    $excludeUids[] = (int)$pageToAdd['uid'];
289
                    $excludeUids[] = $pageIdInDefaultLanguage;
290
                }
291
            }
292
293
            // This is the regular "non-MountPoint page" case (must happen after the if condition so MountPoint
294
            // pages that have been replaced by the Mounted Page will not be added again.
295
            if ($isOnSameSite && !in_array($pageIdInDefaultLanguage, $excludeUids, true)) {
296
                $pages[] = $row;
297
                $excludeUids[] = $pageIdInDefaultLanguage;
298
            }
299
300
            // Add possible sub-pages prepended with the MountPoint page slug
301
            if ($mountPageInformation) {
302
                $siteOfMountedPage = $siteFinder->getSiteByPageId((int)$mountedPage['uid']);
303
                $morePageCandidates = $this->findPageCandidatesOfMountPoint(
304
                    $row,
305
                    $mountedPage,
306
                    $siteOfMountedPage,
307
                    $languageId,
308
                    $slugCandidates
309
                );
310
                foreach ($morePageCandidates as $candidate) {
311
                    // When called previously this MountPoint page should be skipped
312
                    if (in_array((int)$candidate['uid'], $excludeUids, true)) {
313
                        continue;
314
                    }
315
                    $pages[] = $candidate;
316
                }
317
            }
318
        }
319
        return $pages;
320
    }
321
322
    /**
323
     * Check if the page candidate is a mount point, if so, we need to
324
     * re-start the slug candidates procedure with the mount point as a prefix (= context of the subpage).
325
     *
326
     * Before doing the slugCandidates are adapted to remove the slug of the mount point (actively moving the pointer
327
     * of the path to strip away the existing prefix), then checking for more pages.
328
     *
329
     * Once possible candidates are found, the slug prefix needs to be re-added so the PageRouter finds the page,
330
     * with an additional 'MPvar' attribute.
331
     * However, all page candidates needs to be checked if they are connected in the proper mount page.
332
     *
333
     * @param array $mountPointPage the page with doktype=7
334
     * @param array $mountedPage the target page where the mountpoint is pointing to
335
     * @param Site $siteOfMountedPage the site of the target page, which could be different from the current page
336
     * @param int $languageId the current language id
337
     * @param array $slugCandidates the existing slug candidates that were looked for previously
338
     * @return array more candidates
339
     */
340
    protected function findPageCandidatesOfMountPoint(
341
        array $mountPointPage,
342
        array $mountedPage,
343
        Site $siteOfMountedPage,
344
        int $languageId,
345
        array $slugCandidates
346
    ): array {
347
        $pages = [];
348
        $slugOfMountPoint = $mountPointPage['slug'] ?? '';
349
        $commonSlugPrefixOfMountedPage = rtrim($mountedPage['slug'] ?? '', '/');
350
        $narrowedDownSlugPrefixes = [];
351
        foreach ($slugCandidates as $slugCandidate) {
352
            // Remove the mount point prefix (that we just found) from the slug candidates
353
            if (strpos($slugCandidate, $slugOfMountPoint) === 0) {
354
                // Find pages without the common prefix
355
                $narrowedDownSlugPrefix = '/' . trim(substr($slugCandidate, strlen($slugOfMountPoint)), '/');
356
                $narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix;
357
                $narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix . '/';
358
                // Find pages with the prefix of the mounted page as well
359
                if ($commonSlugPrefixOfMountedPage) {
360
                    $narrowedDownSlugPrefix = $commonSlugPrefixOfMountedPage . $narrowedDownSlugPrefix;
361
                    $narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix;
362
                    $narrowedDownSlugPrefixes[] = $narrowedDownSlugPrefix . '/';
363
                }
364
            }
365
        }
366
        $trimmedSlugPrefixes = [];
367
        $narrowedDownSlugPrefixes = array_unique($narrowedDownSlugPrefixes);
368
        foreach ($narrowedDownSlugPrefixes as $narrowedDownSlugPrefix) {
369
            $narrowedDownSlugPrefix = trim($narrowedDownSlugPrefix, '/');
370
            $trimmedSlugPrefixes[] = '/' . $narrowedDownSlugPrefix;
371
            if (!empty($narrowedDownSlugPrefix)) {
372
                $trimmedSlugPrefixes[] = '/' . $narrowedDownSlugPrefix . '/';
373
            }
374
        }
375
        $trimmedSlugPrefixes = array_unique($trimmedSlugPrefixes);
376
        rsort($trimmedSlugPrefixes);
377
378
        $slugProviderForMountPage = GeneralUtility::makeInstance(static::class, $this->context, $siteOfMountedPage, $this->enhancerFactory);
379
        // Find the right pages for which have been matched
380
        $excludedPageIds = [(int)$mountPointPage['uid']];
381
        $pageCandidates = $slugProviderForMountPage->getPagesFromDatabaseForCandidates(
382
            $trimmedSlugPrefixes,
383
            $languageId,
384
            $excludedPageIds
385
        );
386
        // Depending on the "mount_pid_ol" parameter, the mountedPage or the mounted page is in the rootline
387
        $pageWhichMustBeInRootLine = (int)($mountPointPage['mount_pid_ol'] ? $mountedPage['uid'] : $mountPointPage['uid']);
388
        foreach ($pageCandidates as $pageCandidate) {
389
            if (!$pageCandidate['mount_pid_ol']) {
390
                $pageCandidate['MPvar'] = $mountPointPage['MPvar'] . ($pageCandidate['MPvar'] ? ',' . $pageCandidate['MPvar'] : '');
391
            }
392
            // In order to avoid the possibility that any random page like /about-us which is not connected to the mount
393
            // point is not possible to be called via /my-mount-point/about-us, let's check the
394
            $pageCandidateIsConnectedInMountPoint = false;
395
            $rootLine = GeneralUtility::makeInstance(
396
                RootlineUtility::class,
397
                $pageCandidate['uid'],
398
                $pageCandidate['MPvar'],
399
                $this->context
400
            )->get();
401
            foreach ($rootLine as $pageInRootLine) {
402
                if ((int)$pageInRootLine['uid'] === $pageWhichMustBeInRootLine) {
403
                    $pageCandidateIsConnectedInMountPoint = true;
404
                    break;
405
                }
406
            }
407
            if ($pageCandidateIsConnectedInMountPoint === false) {
408
                continue;
409
            }
410
            // Rewrite the slug of the subpage to match the PageRouter matching again
411
            // This is done by first removing the "common" prefix possibly provided by the Mounted Page
412
            // But more importantly adding the $slugOfMountPoint of the MountPoint Page
413
            $slugOfSubpage = $pageCandidate['slug'];
414
            if ($commonSlugPrefixOfMountedPage && strpos($slugOfSubpage, $commonSlugPrefixOfMountedPage) === 0) {
415
                $slugOfSubpage = substr($slugOfSubpage, strlen($commonSlugPrefixOfMountedPage));
416
            }
417
            $pageCandidate['slug'] = $slugOfMountPoint . (($slugOfSubpage && $slugOfSubpage !== '/') ? '/' . trim($slugOfSubpage, '/') : '');
418
            $pages[] = $pageCandidate;
419
        }
420
        return $pages;
421
    }
422
423
    /**
424
     * Returns possible URL parts for a string like /home/about-us/offices/ or /home/about-us/offices.json
425
     * to return.
426
     *
427
     * /home/about-us/offices/
428
     * /home/about-us/offices.json
429
     * /home/about-us/offices
430
     * /home/about-us/
431
     * /home/about-us
432
     * /home/
433
     * /home
434
     * /
435
     *
436
     * @param string $routePath
437
     * @return string[]
438
     */
439
    protected function getCandidateSlugsFromRoutePath(string $routePath): array
440
    {
441
        $redecorationPattern = $this->getRoutePathRedecorationPattern();
442
        if (!empty($redecorationPattern) && preg_match('#' . $redecorationPattern . '#', $routePath, $matches)) {
443
            $decoration = $matches['decoration'];
444
            $decorationPattern = preg_quote($decoration, '#');
445
            $routePath = preg_replace('#' . $decorationPattern . '$#', '', $routePath);
446
        }
447
448
        $candidatePathParts = [];
449
        $pathParts = GeneralUtility::trimExplode('/', $routePath, true);
450
        if (empty($pathParts)) {
451
            return ['/'];
452
        }
453
454
        while (!empty($pathParts)) {
455
            $prefix = '/' . implode('/', $pathParts);
456
            $candidatePathParts[] = $prefix . '/';
457
            $candidatePathParts[] = $prefix;
458
            array_pop($pathParts);
459
        }
460
        $candidatePathParts[] = '/';
461
        return $candidatePathParts;
462
    }
463
}
464