[TASK] Avoid slow array functions in loops
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Classes / Controller / Page / TreeController.php
1 <?php
2 declare(strict_types = 1);
3 namespace TYPO3\CMS\Backend\Controller\Page;
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 use Psr\Http\Message\ResponseInterface;
19 use Psr\Http\Message\ServerRequestInterface;
20 use TYPO3\CMS\Backend\Configuration\BackendUserConfiguration;
21 use TYPO3\CMS\Backend\Tree\Repository\PageTreeRepository;
22 use TYPO3\CMS\Backend\Utility\BackendUtility;
23 use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
24 use TYPO3\CMS\Core\Database\Query\Restriction\DocumentTypeExclusionRestriction;
25 use TYPO3\CMS\Core\Exception\Page\RootLineException;
26 use TYPO3\CMS\Core\Exception\SiteNotFoundException;
27 use TYPO3\CMS\Core\Http\JsonResponse;
28 use TYPO3\CMS\Core\Imaging\Icon;
29 use TYPO3\CMS\Core\Imaging\IconFactory;
30 use TYPO3\CMS\Core\Localization\LanguageService;
31 use TYPO3\CMS\Core\Site\SiteFinder;
32 use TYPO3\CMS\Core\Type\Bitmask\JsConfirmation;
33 use TYPO3\CMS\Core\Type\Bitmask\Permission;
34 use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
35 use TYPO3\CMS\Core\Utility\GeneralUtility;
36 use TYPO3\CMS\Core\Utility\RootlineUtility;
37 use TYPO3\CMS\Workspaces\Service\WorkspaceService;
38
39 /**
40 * Controller providing data to the page tree
41 * @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
42 */
43 class TreeController
44 {
45 /**
46 * Option to use the nav_title field for outputting in the tree items, set via userTS.
47 *
48 * @var bool
49 */
50 protected $useNavTitle = false;
51
52 /**
53 * Option to prefix the page ID when outputting the tree items, set via userTS.
54 *
55 * @var bool
56 */
57 protected $addIdAsPrefix = false;
58
59 /**
60 * Option to prefix the domain name of sys_domains when outputting the tree items, set via userTS.
61 *
62 * @var bool
63 */
64 protected $addDomainName = false;
65
66 /**
67 * Option to add the rootline path above each mount point, set via userTS.
68 *
69 * @var bool
70 */
71 protected $showMountPathAboveMounts = false;
72
73 /**
74 * An array of background colors for a branch in the tree, set via userTS.
75 *
76 * @var array
77 */
78 protected $backgroundColors = [];
79
80 /**
81 * A list of pages not to be shown.
82 *
83 * @var array
84 */
85 protected $hiddenRecords = [];
86
87 /**
88 * Contains the state of all items that are expanded.
89 *
90 * @var array
91 */
92 protected $expandedState = [];
93
94 /**
95 * Instance of the icon factory, to be used for generating the items.
96 *
97 * @var IconFactory
98 */
99 protected $iconFactory;
100
101 /**
102 * Constructor to set up common objects needed in various places.
103 */
104 public function __construct()
105 {
106 $this->iconFactory = GeneralUtility::makeInstance(IconFactory::class);
107 $this->useNavTitle = (bool)($this->getBackendUser()->getTSConfig()['options.']['pageTree.']['showNavTitle'] ?? false);
108 }
109
110 /**
111 * Returns page tree configuration in JSON
112 *
113 * @return ResponseInterface
114 */
115 public function fetchConfigurationAction(): ResponseInterface
116 {
117 $configuration = [
118 'allowRecursiveDelete' => !empty($this->getBackendUser()->uc['recursiveDelete']),
119 'doktypes' => $this->getDokTypes(),
120 'displayDeleteConfirmation' => $this->getBackendUser()->jsConfirmation(JsConfirmation::DELETE),
121 'temporaryMountPoint' => $this->getMountPointPath((int)($this->getBackendUser()->uc['pageTree_temporaryMountPoint'] ?? 0)),
122 ];
123
124 return new JsonResponse($configuration);
125 }
126
127 /**
128 * Returns the list of doktypes to display in page tree toolbar drag area
129 *
130 * Note: The list can be filtered by the user TypoScript
131 * option "options.pageTree.doktypesToShowInNewPageDragArea".
132 *
133 * @return array
134 */
135 protected function getDokTypes(): array
136 {
137 $backendUser = $this->getBackendUser();
138 $doktypeLabelMap = [];
139 foreach ($GLOBALS['TCA']['pages']['columns']['doktype']['config']['items'] as $doktypeItemConfig) {
140 if ($doktypeItemConfig[1] === '--div--') {
141 continue;
142 }
143 $doktypeLabelMap[$doktypeItemConfig[1]] = $doktypeItemConfig[0];
144 }
145 $doktypes = GeneralUtility::intExplode(',', $backendUser->getTSConfig()['options.']['pageTree.']['doktypesToShowInNewPageDragArea'] ?? '', true);
146 $output = [];
147 $allowedDoktypes = GeneralUtility::intExplode(',', $backendUser->groupData['pagetypes_select'], true);
148 $isAdmin = $backendUser->isAdmin();
149 // Early return if backend user may not create any doktype
150 if (!$isAdmin && empty($allowedDoktypes)) {
151 return $output;
152 }
153 foreach ($doktypes as $doktype) {
154 if (!$isAdmin && !in_array($doktype, $allowedDoktypes, true)) {
155 continue;
156 }
157 $label = htmlspecialchars($this->getLanguageService()->sL($doktypeLabelMap[$doktype]));
158 $output[] = [
159 'nodeType' => $doktype,
160 'icon' => $GLOBALS['TCA']['pages']['ctrl']['typeicon_classes'][$doktype] ?? '',
161 'title' => $label,
162 'tooltip' => $label
163 ];
164 }
165 return $output;
166 }
167
168 /**
169 * Returns JSON representing page tree
170 *
171 * @param ServerRequestInterface $request
172 * @return ResponseInterface
173 */
174 public function fetchDataAction(ServerRequestInterface $request): ResponseInterface
175 {
176 $userTsConfig = $this->getBackendUser()->getTSConfig();
177 $this->hiddenRecords = GeneralUtility::intExplode(',', $userTsConfig['options.']['hideRecords.']['pages'] ?? '', true);
178 $this->backgroundColors = $userTsConfig['options.']['pageTree.']['backgroundColor.'] ?? [];
179 $this->addIdAsPrefix = (bool)($userTsConfig['options.']['pageTree.']['showPageIdWithTitle'] ?? false);
180 $this->addDomainName = (bool)($userTsConfig['options.']['pageTree.']['showDomainNameWithTitle'] ?? false);
181 $this->showMountPathAboveMounts = (bool)($userTsConfig['options.']['pageTree.']['showPathAboveMounts'] ?? false);
182 $backendUserConfiguration = GeneralUtility::makeInstance(BackendUserConfiguration::class);
183 $this->expandedState = $backendUserConfiguration->get('BackendComponents.States.Pagetree');
184 if (is_object($this->expandedState) && is_object($this->expandedState->stateHash)) {
185 $this->expandedState = (array)$this->expandedState->stateHash;
186 } else {
187 $this->expandedState = $this->expandedState['stateHash'] ?: [];
188 }
189
190 // Fetching a part of a pagetree
191 if (!empty($request->getQueryParams()['pid'])) {
192 $entryPoints = [(int)$request->getQueryParams()['pid']];
193 } else {
194 $entryPoints = $this->getAllEntryPointPageTrees();
195 }
196 $items = [[]];
197 foreach ($entryPoints as $page) {
198 $items[] = $this->pagesToFlatArray($page, (int)$page['uid']);
199 }
200
201 return new JsonResponse(array_merge(...$items));
202 }
203
204 /**
205 * Sets a temporary mount point
206 *
207 * @param ServerRequestInterface $request
208 * @return ResponseInterface
209 * @throws \RuntimeException
210 */
211 public function setTemporaryMountPointAction(ServerRequestInterface $request): ResponseInterface
212 {
213 if (empty($request->getParsedBody()['pid'])) {
214 throw new \RuntimeException(
215 'Required "pid" parameter is missing.',
216 1511792197
217 );
218 }
219 $pid = (int)$request->getParsedBody()['pid'];
220
221 $this->getBackendUser()->uc['pageTree_temporaryMountPoint'] = $pid;
222 $this->getBackendUser()->writeUC();
223 $response = [
224 'mountPointPath' => $this->getMountPointPath($pid)
225 ];
226 return new JsonResponse($response);
227 }
228
229 /**
230 * Converts nested tree structure produced by PageTreeRepository to a flat, one level array
231 * and also adds visual representation information to the data.
232 *
233 * @param array $page
234 * @param int $entryPoint
235 * @param int $depth
236 * @param array $inheritedData
237 * @return array
238 */
239 protected function pagesToFlatArray(array $page, int $entryPoint, int $depth = 0, array $inheritedData = []): array
240 {
241 $pageId = (int)$page['uid'];
242 if (in_array($pageId, $this->hiddenRecords, true)) {
243 return [];
244 }
245
246 $stopPageTree = !empty($page['php_tree_stop']) && $depth > 0;
247 $identifier = $entryPoint . '_' . $pageId;
248 $expanded = !empty($page['expanded']) || (isset($this->expandedState[$identifier]) && $this->expandedState[$identifier]);
249 $backgroundColor = !empty($this->backgroundColors[$pageId]) ? $this->backgroundColors[$pageId] : ($inheritedData['backgroundColor'] ?? '');
250
251 $suffix = '';
252 $prefix = '';
253 $nameSourceField = 'title';
254 $visibleText = $page['title'];
255 $tooltip = BackendUtility::titleAttribForPages($page, '', false);
256 if ($pageId !== 0) {
257 $icon = $this->iconFactory->getIconForRecord('pages', $page, Icon::SIZE_SMALL);
258 } else {
259 $icon = $this->iconFactory->getIcon('apps-pagetree-root', Icon::SIZE_SMALL);
260 }
261
262 if ($this->useNavTitle && trim($page['nav_title'] ?? '') !== '') {
263 $nameSourceField = 'nav_title';
264 $visibleText = $page['nav_title'];
265 }
266 if (trim($visibleText) === '') {
267 $visibleText = htmlspecialchars('[' . $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.no_title') . ']');
268 }
269
270 if ($this->addDomainName && $page['is_siteroot']) {
271 $domain = $this->getDomainNameForPage($pageId);
272 $suffix = $domain !== '' ? ' [' . $domain . ']' : '';
273 }
274
275 $lockInfo = BackendUtility::isRecordLocked('pages', $pageId);
276 if (is_array($lockInfo)) {
277 $tooltip .= ' - ' . $lockInfo['msg'];
278 }
279 if ($this->addIdAsPrefix) {
280 $prefix = htmlspecialchars('[' . $pageId . '] ');
281 }
282
283 $items = [[]];
284 $items[] = [[
285 // Used to track if the tree item is collapsed or not
286 'stateIdentifier' => $identifier,
287 'identifier' => $pageId,
288 'depth' => $depth,
289 'tip' => htmlspecialchars($tooltip),
290 'hasChildren' => !empty($page['_children']),
291 'icon' => $icon->getIdentifier(),
292 'name' => $visibleText,
293 'nameSourceField' => $nameSourceField,
294 'prefix' => htmlspecialchars($prefix),
295 'suffix' => htmlspecialchars($suffix),
296 'locked' => is_array($lockInfo),
297 'overlayIcon' => $icon->getOverlayIcon() ? $icon->getOverlayIcon()->getIdentifier() : '',
298 'selectable' => true,
299 'expanded' => (bool)$expanded,
300 'checked' => false,
301 'backgroundColor' => htmlspecialchars($backgroundColor),
302 'stopPageTree' => $stopPageTree,
303 'class' => $this->resolvePageCssClassNames($page),
304 'readableRootline' => $depth === 0 && $this->showMountPathAboveMounts ? $this->getMountPointPath($pageId) : '',
305 'isMountPoint' => $depth === 0,
306 'mountPoint' => $entryPoint,
307 'workspaceId' => !empty($page['t3ver_oid']) ? $page['t3ver_oid'] : $pageId,
308 ]];
309 if (!$stopPageTree) {
310 foreach ($page['_children'] as $child) {
311 $items[] = $this->pagesToFlatArray($child, $entryPoint, $depth + 1, ['backgroundColor' => $backgroundColor]);
312 }
313 }
314 return array_merge(...$items);
315 }
316
317 /**
318 * Fetches all entry points for the page tree that the user is allowed to see
319 *
320 * @return array
321 */
322 protected function getAllEntryPointPageTrees(): array
323 {
324 $backendUser = $this->getBackendUser();
325
326 $userTsConfig = $this->getBackendUser()->getTSConfig();
327 $excludedDocumentTypes = GeneralUtility::intExplode(',', $userTsConfig['options.']['pageTree.']['excludeDoktypes'] ?? '', true);
328
329 $additionalPageTreeQueryRestrictions = [];
330 if (!empty($excludedDocumentTypes)) {
331 foreach ($excludedDocumentTypes as $excludedDocumentType) {
332 $additionalPageTreeQueryRestrictions[] = new DocumentTypeExclusionRestriction((int)$excludedDocumentType);
333 }
334 }
335
336 $repository = GeneralUtility::makeInstance(PageTreeRepository::class, (int)$backendUser->workspace, [], $additionalPageTreeQueryRestrictions);
337
338 $entryPoints = (int)($backendUser->uc['pageTree_temporaryMountPoint'] ?? 0);
339 if ($entryPoints > 0) {
340 $entryPoints = [$entryPoints];
341 } else {
342 $entryPoints = array_map('intval', $backendUser->returnWebmounts());
343 $entryPoints = array_unique($entryPoints);
344 if (empty($entryPoints)) {
345 // use a virtual root
346 // the real mount points will be fetched in getNodes() then
347 // since those will be the "sub pages" of the virtual root
348 $entryPoints = [0];
349 }
350 }
351 if (empty($entryPoints)) {
352 return [];
353 }
354
355 foreach ($entryPoints as $k => &$entryPoint) {
356 if (in_array($entryPoint, $this->hiddenRecords, true)) {
357 unset($entryPoints[$k]);
358 continue;
359 }
360
361 if (!empty($this->backgroundColors) && is_array($this->backgroundColors)) {
362 try {
363 $entryPointRootLine = GeneralUtility::makeInstance(RootlineUtility::class, $entryPoint)->get();
364 } catch (RootLineException $e) {
365 $entryPointRootLine = [];
366 }
367 foreach ($entryPointRootLine as $rootLineEntry) {
368 $parentUid = $rootLineEntry['uid'];
369 if (!empty($this->backgroundColors[$parentUid]) && empty($this->backgroundColors[$entryPoint])) {
370 $this->backgroundColors[$entryPoint] = $this->backgroundColors[$parentUid];
371 }
372 }
373 }
374
375 $entryPoint = $repository->getTree($entryPoint, function ($page) use ($backendUser) {
376 // check each page if the user has permission to access it
377 return $backendUser->doesUserHaveAccess($page, Permission::PAGE_SHOW);
378 });
379 if (!is_array($entryPoint)) {
380 unset($entryPoints[$k]);
381 }
382 }
383
384 return $entryPoints;
385 }
386
387 /**
388 * Returns the first configured domain name for a page
389 *
390 * @param int $pageId
391 * @return string
392 */
393 protected function getDomainNameForPage(int $pageId): string
394 {
395 $domain = '';
396 $siteFinder = GeneralUtility::makeInstance(SiteFinder::class);
397 try {
398 $site = $siteFinder->getSiteByRootPageId($pageId);
399 $domain = (string)$site->getBase();
400 } catch (SiteNotFoundException $e) {
401 // No site found
402 }
403
404 return $domain;
405 }
406
407 /**
408 * Returns the mount point path for a temporary mount or the given id
409 *
410 * @param int $uid
411 * @return string
412 */
413 protected function getMountPointPath(int $uid): string
414 {
415 if ($uid <= 0) {
416 return '';
417 }
418 $rootline = array_reverse(BackendUtility::BEgetRootLine($uid));
419 array_shift($rootline);
420 $path = [];
421 foreach ($rootline as $rootlineElement) {
422 $record = BackendUtility::getRecordWSOL('pages', $rootlineElement['uid'], 'title, nav_title', '', true, true);
423 $text = $record['title'];
424 if ($this->useNavTitle && trim($record['nav_title'] ?? '') !== '') {
425 $text = $record['nav_title'];
426 }
427 $path[] = htmlspecialchars($text);
428 }
429 return '/' . implode('/', $path);
430 }
431
432 /**
433 * Fetches possible css class names to be used when a record was modified in a workspace
434 *
435 * @param array $page Page record (workspace overlaid)
436 * @return string CSS class names to be applied
437 */
438 protected function resolvePageCssClassNames(array $page): string
439 {
440 $classes = [];
441
442 if ($page['uid'] === 0) {
443 return '';
444 }
445 $workspaceId = (int)$this->getBackendUser()->workspace;
446 if ($workspaceId > 0 && ExtensionManagementUtility::isLoaded('workspaces')) {
447 if ($page['t3ver_oid'] > 0 && (int)$page['t3ver_wsid'] === $workspaceId) {
448 $classes[] = 'ver-element';
449 $classes[] = 'ver-versions';
450 } elseif (
451 $this->getWorkspaceService()->hasPageRecordVersions(
452 $workspaceId,
453 $page['t3ver_oid'] ?: $page['uid']
454 )
455 ) {
456 $classes[] = 'ver-versions';
457 }
458 }
459
460 return implode(' ', $classes);
461 }
462
463 /**
464 * @return WorkspaceService
465 */
466 protected function getWorkspaceService(): WorkspaceService
467 {
468 return GeneralUtility::makeInstance(WorkspaceService::class);
469 }
470
471 /**
472 * @return BackendUserAuthentication
473 */
474 protected function getBackendUser(): BackendUserAuthentication
475 {
476 return $GLOBALS['BE_USER'];
477 }
478
479 /**
480 * @return LanguageService|null
481 */
482 protected function getLanguageService(): ?LanguageService
483 {
484 return $GLOBALS['LANG'] ?? null;
485 }
486 }