a182cb506620d75c3bef57895448b9fca826a6c6
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Utility / RootlineUtility.php
1 <?php
2 namespace TYPO3\CMS\Core\Utility;
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 use Doctrine\DBAL\DBALException;
18 use TYPO3\CMS\Core\Database\ConnectionPool;
19 use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
20 use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
21 use TYPO3\CMS\Frontend\Page\PageRepository;
22
23 /**
24 * A utility resolving and Caching the Rootline generation
25 */
26 class RootlineUtility
27 {
28 /**
29 * @var int
30 */
31 protected $pageUid;
32
33 /**
34 * @var string
35 */
36 protected $mountPointParameter;
37
38 /**
39 * @var array
40 */
41 protected $parsedMountPointParameters = [];
42
43 /**
44 * @var int
45 */
46 protected $languageUid = 0;
47
48 /**
49 * @var int
50 */
51 protected $workspaceUid = 0;
52
53 /**
54 * @var bool
55 */
56 protected $versionPreview = false;
57
58 /**
59 * @var \TYPO3\CMS\Core\Cache\Frontend\FrontendInterface
60 */
61 protected static $cache = null;
62
63 /**
64 * @var array
65 */
66 protected static $localCache = [];
67
68 /**
69 * Fields to fetch when populating rootline data
70 *
71 * @var array
72 */
73 protected static $rootlineFields = [
74 'pid',
75 'uid',
76 't3ver_oid',
77 't3ver_wsid',
78 't3ver_state',
79 'title',
80 'alias',
81 'nav_title',
82 'media',
83 'layout',
84 'hidden',
85 'starttime',
86 'endtime',
87 'fe_group',
88 'extendToSubpages',
89 'doktype',
90 'TSconfig',
91 'tsconfig_includes',
92 'is_siteroot',
93 'mount_pid',
94 'mount_pid_ol',
95 'fe_login_mode',
96 'backend_layout_next_level'
97 ];
98
99 /**
100 * Rootline Context
101 *
102 * @var \TYPO3\CMS\Frontend\Page\PageRepository
103 */
104 protected $pageContext;
105
106 /**
107 * @var string
108 */
109 protected $cacheIdentifier;
110
111 /**
112 * @var array
113 */
114 protected static $pageRecordCache = [];
115
116 /**
117 * @param int $uid
118 * @param string $mountPointParameter
119 * @param \TYPO3\CMS\Frontend\Page\PageRepository $context
120 * @throws \RuntimeException
121 */
122 public function __construct($uid, $mountPointParameter = '', PageRepository $context = null)
123 {
124 $this->pageUid = (int)$uid;
125 $this->mountPointParameter = trim($mountPointParameter);
126 if ($context === null) {
127 if (isset($GLOBALS['TSFE']) && is_object($GLOBALS['TSFE']) && is_object($GLOBALS['TSFE']->sys_page)) {
128 $this->pageContext = $GLOBALS['TSFE']->sys_page;
129 } else {
130 $this->pageContext = GeneralUtility::makeInstance(\TYPO3\CMS\Frontend\Page\PageRepository::class);
131 }
132 } else {
133 $this->pageContext = $context;
134 }
135 $this->initializeObject();
136 }
137
138 /**
139 * Initialize a state to work with
140 *
141 * @throws \RuntimeException
142 */
143 protected function initializeObject()
144 {
145 $this->languageUid = (int)$this->pageContext->sys_language_uid;
146 $this->workspaceUid = (int)$this->pageContext->versioningWorkspaceId;
147 $this->versionPreview = $this->pageContext->versioningPreview;
148 if ($this->mountPointParameter !== '') {
149 if (!$GLOBALS['TYPO3_CONF_VARS']['FE']['enable_mount_pids']) {
150 throw new \RuntimeException('Mount-Point Pages are disabled for this installation. Cannot resolve a Rootline for a page with Mount-Points', 1343462896);
151 } else {
152 $this->parseMountPointParameter();
153 }
154 }
155 if (self::$cache === null) {
156 self::$cache = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Cache\CacheManager::class)->getCache('cache_rootline');
157 }
158 self::$rootlineFields = array_merge(self::$rootlineFields, GeneralUtility::trimExplode(',', $GLOBALS['TYPO3_CONF_VARS']['FE']['addRootLineFields'], true));
159 self::$rootlineFields = array_unique(self::$rootlineFields);
160
161 $this->cacheIdentifier = $this->getCacheIdentifier();
162 }
163
164 /**
165 * Purges all rootline caches.
166 *
167 * Note: This function is intended to be used in unit tests only.
168 */
169 public static function purgeCaches()
170 {
171 self::$localCache = [];
172 self::$pageRecordCache = [];
173 }
174
175 /**
176 * Constructs the cache Identifier
177 *
178 * @param int $otherUid
179 * @return string
180 */
181 public function getCacheIdentifier($otherUid = null)
182 {
183 $mountPointParameter = (string)$this->mountPointParameter;
184 if ($mountPointParameter !== '' && strpos($mountPointParameter, ',') !== false) {
185 $mountPointParameter = str_replace(',', '__', $mountPointParameter);
186 }
187
188 return implode('_', [
189 $otherUid !== null ? (int)$otherUid : $this->pageUid,
190 $mountPointParameter,
191 $this->languageUid,
192 $this->workspaceUid,
193 $this->versionPreview ? 1 : 0
194 ]);
195 }
196
197 /**
198 * Returns the actual rootline
199 *
200 * @return array
201 */
202 public function get()
203 {
204 if (!isset(static::$localCache[$this->cacheIdentifier])) {
205 $entry = static::$cache->get($this->cacheIdentifier);
206 if (!$entry) {
207 $this->generateRootlineCache();
208 } else {
209 static::$localCache[$this->cacheIdentifier] = $entry;
210 $depth = count($entry);
211 // Populate the root-lines for parent pages as well
212 // since they are part of the current root-line
213 while ($depth > 1) {
214 --$depth;
215 $parentCacheIdentifier = $this->getCacheIdentifier($entry[$depth - 1]['uid']);
216 // Abort if the root-line of the parent page is
217 // already in the local cache data
218 if (isset(static::$localCache[$parentCacheIdentifier])) {
219 break;
220 }
221 // Behaves similar to array_shift(), but preserves
222 // the array keys - which contain the page ids here
223 $entry = array_slice($entry, 1, null, true);
224 static::$localCache[$parentCacheIdentifier] = $entry;
225 }
226 }
227 }
228 return static::$localCache[$this->cacheIdentifier];
229 }
230
231 /**
232 * Queries the database for the page record and returns it.
233 *
234 * @param int $uid Page id
235 * @throws \RuntimeException
236 * @return array
237 */
238 protected function getRecordArray($uid)
239 {
240 $currentCacheIdentifier = $this->getCacheIdentifier($uid);
241 if (!isset(self::$pageRecordCache[$currentCacheIdentifier])) {
242 $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('pages');
243 $queryBuilder->getRestrictions()->removeAll()->add(GeneralUtility::makeInstance(DeletedRestriction::class));
244 $row = $queryBuilder->select(...self::$rootlineFields)
245 ->from('pages')
246 ->where(
247 $queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)),
248 $queryBuilder->expr()->neq(
249 'doktype',
250 $queryBuilder->createNamedParameter(PageRepository::DOKTYPE_RECYCLER, \PDO::PARAM_INT)
251 )
252 )
253 ->execute()
254 ->fetch();
255 if (empty($row)) {
256 throw new \RuntimeException('Could not fetch page data for uid ' . $uid . '.', 1343589451);
257 }
258 $this->pageContext->versionOL('pages', $row, false, true);
259 $this->pageContext->fixVersioningPid('pages', $row);
260 if (is_array($row)) {
261 if ($this->languageUid > 0) {
262 $row = $this->pageContext->getPageOverlay($row, $this->languageUid);
263 }
264 $row = $this->enrichWithRelationFields($row['_PAGES_OVERLAY_UID'] ?? $uid, $row);
265 self::$pageRecordCache[$currentCacheIdentifier] = $row;
266 }
267 }
268 if (!is_array(self::$pageRecordCache[$currentCacheIdentifier])) {
269 throw new \RuntimeException('Broken rootline. Could not resolve page with uid ' . $uid . '.', 1343464101);
270 }
271 return self::$pageRecordCache[$currentCacheIdentifier];
272 }
273
274 /**
275 * Resolve relations as defined in TCA and add them to the provided $pageRecord array.
276 *
277 * @param int $uid Either pages.uid or pages_language_overlay.uid if localized
278 * @param array $pageRecord Page record (possibly overlaid) to be extended with relations
279 * @throws \RuntimeException
280 * @return array $pageRecord with additional relations
281 */
282 protected function enrichWithRelationFields($uid, array $pageRecord)
283 {
284 $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
285
286 // @todo Remove this special interpretation of relations by consequently using RelationHandler
287 foreach ($GLOBALS['TCA']['pages']['columns'] as $column => $configuration) {
288 if ($this->columnHasRelationToResolve($configuration)) {
289 $configuration = $configuration['config'];
290 if ($configuration['MM']) {
291 /** @var $loadDBGroup \TYPO3\CMS\Core\Database\RelationHandler */
292 $loadDBGroup = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Database\RelationHandler::class);
293 $loadDBGroup->start(
294 $pageRecord[$column],
295 // @todo That depends on the type (group, select, inline)
296 isset($configuration['allowed']) ? $configuration['allowed'] : $configuration['foreign_table'],
297 $configuration['MM'],
298 $uid,
299 'pages',
300 $configuration
301 );
302 $relatedUids = isset($loadDBGroup->tableArray[$configuration['foreign_table']])
303 ? $loadDBGroup->tableArray[$configuration['foreign_table']]
304 : [];
305 } else {
306 // @todo The assumption is wrong, since group can be used without "MM", but having "allowed"
307 $table = $configuration['foreign_table'];
308
309 $queryBuilder = $connectionPool->getQueryBuilderForTable($table);
310 $queryBuilder->getRestrictions()->removeAll()
311 ->add(GeneralUtility::makeInstance(DeletedRestriction::class))
312 ->add(GeneralUtility::makeInstance(HiddenRestriction::class));
313 $queryBuilder->select('uid')
314 ->from($table)
315 ->where(
316 $queryBuilder->expr()->eq(
317 $configuration['foreign_field'],
318 $queryBuilder->createNamedParameter(
319 $uid,
320 \PDO::PARAM_INT
321 )
322 )
323 );
324
325 if (isset($configuration['foreign_match_fields']) && is_array($configuration['foreign_match_fields'])) {
326 foreach ($configuration['foreign_match_fields'] as $field => $value) {
327 $queryBuilder->andWhere(
328 $queryBuilder->expr()->eq(
329 $field,
330 $queryBuilder->createNamedParameter($value, \PDO::PARAM_STR)
331 )
332 );
333 }
334 }
335 if (isset($configuration['foreign_table_field'])) {
336 $queryBuilder->andWhere(
337 $queryBuilder->expr()->eq(
338 trim($configuration['foreign_table_field']),
339 $queryBuilder->createNamedParameter(
340 (int)$this->languageUid > 0 ? 'pages_language_overlay' : 'pages',
341 \PDO::PARAM_STR
342 )
343 )
344 );
345 }
346 if (isset($configuration['foreign_sortby'])) {
347 $queryBuilder->orderBy($configuration['foreign_sortby']);
348 }
349 try {
350 $statement = $queryBuilder->execute();
351 } catch (DBALException $e) {
352 throw new \RuntimeException('Could to resolve related records for page ' . $uid . ' and foreign_table ' . htmlspecialchars($table), 1343589452);
353 }
354 $relatedUids = [];
355 while ($row = $statement->fetch()) {
356 $relatedUids[] = $row['uid'];
357 }
358 }
359 $pageRecord[$column] = implode(',', $relatedUids);
360 }
361 }
362 return $pageRecord;
363 }
364
365 /**
366 * Checks whether the TCA Configuration array of a column
367 * describes a relation which is not stored as CSV in the record
368 *
369 * @param array $configuration TCA configuration to check
370 * @return bool TRUE, if it describes a non-CSV relation
371 */
372 protected function columnHasRelationToResolve(array $configuration)
373 {
374 $configuration = $configuration['config'];
375 if (!empty($configuration['MM']) && !empty($configuration['type']) && in_array($configuration['type'], ['select', 'inline', 'group'])) {
376 return true;
377 }
378 if (!empty($configuration['foreign_field']) && !empty($configuration['type']) && in_array($configuration['type'], ['select', 'inline'])) {
379 return true;
380 }
381 return false;
382 }
383
384 /**
385 * Actual function to generate the rootline and cache it
386 *
387 * @throws \RuntimeException
388 */
389 protected function generateRootlineCache()
390 {
391 $page = $this->getRecordArray($this->pageUid);
392 // If the current page is a mounted (according to the MP parameter) handle the mount-point
393 if ($this->isMountedPage()) {
394 $mountPoint = $this->getRecordArray($this->parsedMountPointParameters[$this->pageUid]);
395 $page = $this->processMountedPage($page, $mountPoint);
396 $parentUid = $mountPoint['pid'];
397 // Anyhow after reaching the mount-point, we have to go up that rootline
398 unset($this->parsedMountPointParameters[$this->pageUid]);
399 } else {
400 $parentUid = $page['pid'];
401 }
402 $cacheTags = ['pageId_' . $page['uid']];
403 if ($parentUid > 0) {
404 // Get rootline of (and including) parent page
405 $mountPointParameter = !empty($this->parsedMountPointParameters) ? $this->mountPointParameter : '';
406 /** @var $rootline \TYPO3\CMS\Core\Utility\RootlineUtility */
407 $rootline = GeneralUtility::makeInstance(\TYPO3\CMS\Core\Utility\RootlineUtility::class, $parentUid, $mountPointParameter, $this->pageContext);
408 $rootline = $rootline->get();
409 // retrieve cache tags of parent rootline
410 foreach ($rootline as $entry) {
411 $cacheTags[] = 'pageId_' . $entry['uid'];
412 if ($entry['uid'] == $this->pageUid) {
413 throw new \RuntimeException('Circular connection in rootline for page with uid ' . $this->pageUid . ' found. Check your mountpoint configuration.', 1343464103);
414 }
415 }
416 } else {
417 $rootline = [];
418 }
419 $rootline[] = $page;
420 krsort($rootline);
421 static::$cache->set($this->cacheIdentifier, $rootline, $cacheTags);
422 static::$localCache[$this->cacheIdentifier] = $rootline;
423 }
424
425 /**
426 * Checks whether the current Page is a Mounted Page
427 * (according to the MP-URL-Parameter)
428 *
429 * @return bool
430 */
431 public function isMountedPage()
432 {
433 return in_array($this->pageUid, array_keys($this->parsedMountPointParameters));
434 }
435
436 /**
437 * Enhances with mount point information or replaces the node if needed
438 *
439 * @param array $mountedPageData page record array of mounted page
440 * @param array $mountPointPageData page record array of mount point page
441 * @throws \RuntimeException
442 * @return array
443 */
444 protected function processMountedPage(array $mountedPageData, array $mountPointPageData)
445 {
446 if ($mountPointPageData['mount_pid'] != $mountedPageData['uid']) {
447 throw new \RuntimeException('Broken rootline. Mountpoint parameter does not match the actual rootline. mount_pid (' . $mountPointPageData['mount_pid'] . ') does not match page uid (' . $mountedPageData['uid'] . ').', 1343464100);
448 }
449 // Current page replaces the original mount-page
450 if ($mountPointPageData['mount_pid_ol']) {
451 $mountedPageData['_MOUNT_OL'] = true;
452 $mountedPageData['_MOUNT_PAGE'] = [
453 'uid' => $mountPointPageData['uid'],
454 'pid' => $mountPointPageData['pid'],
455 'title' => $mountPointPageData['title']
456 ];
457 } else {
458 // The mount-page is not replaced, the mount-page itself has to be used
459 $mountedPageData = $mountPointPageData;
460 }
461 $mountedPageData['_MOUNTED_FROM'] = $this->pageUid;
462 $mountedPageData['_MP_PARAM'] = $this->pageUid . '-' . $mountPointPageData['uid'];
463 return $mountedPageData;
464 }
465
466 /**
467 * Parse the MountPoint Parameters
468 * Splits the MP-Param via "," for several nested mountpoints
469 * and afterwords registers the mountpoint configurations
470 */
471 protected function parseMountPointParameter()
472 {
473 $mountPoints = GeneralUtility::trimExplode(',', $this->mountPointParameter);
474 foreach ($mountPoints as $mP) {
475 list($mountedPageUid, $mountPageUid) = GeneralUtility::intExplode('-', $mP);
476 $this->parsedMountPointParameters[$mountedPageUid] = $mountPageUid;
477 }
478 }
479 }