[TASK] Use BE Routing / PSR-7 instead of BackendUtility::getModuleUrl
[Packages/TYPO3.CMS.git] / typo3 / sysext / backend / Classes / ContextMenu / ItemProviders / RecordProvider.php
1 <?php
2 declare(strict_types=1);
3 namespace TYPO3\CMS\Backend\ContextMenu\ItemProviders;
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 TYPO3\CMS\Backend\Routing\UriBuilder;
19 use TYPO3\CMS\Backend\Utility\BackendUtility;
20 use TYPO3\CMS\Core\Type\Bitmask\JsConfirmation;
21 use TYPO3\CMS\Core\Type\Bitmask\Permission;
22 use TYPO3\CMS\Core\Utility\GeneralUtility;
23 use TYPO3\CMS\Core\Versioning\VersionState;
24
25 /**
26 * Class responsible for providing click menu items for db records which don't have custom provider (as e.g. pages)
27 */
28 class RecordProvider extends AbstractProvider
29 {
30 /**
31 * Database record
32 *
33 * @var array
34 */
35 protected $record = [];
36
37 /**
38 * Database record of the page $this->record is placed on
39 *
40 * @var array
41 */
42 protected $pageRecord = [];
43
44 /**
45 * Local cache for the result of BackendUserAuthentication::calcPerms()
46 *
47 * @var int
48 */
49 protected $pagePermissions = 0;
50
51 /**
52 * @var array
53 */
54 protected $itemsConfiguration = [
55 'view' => [
56 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.view',
57 'iconIdentifier' => 'actions-view',
58 'callbackAction' => 'viewRecord'
59 ],
60 'edit' => [
61 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.edit',
62 'iconIdentifier' => 'actions-open',
63 'callbackAction' => 'editRecord'
64 ],
65 'new' => [
66 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.new',
67 'iconIdentifier' => 'actions-add',
68 'callbackAction' => 'newRecord'
69 ],
70 'info' => [
71 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.info',
72 'iconIdentifier' => 'actions-document-info',
73 'callbackAction' => 'openInfoPopUp'
74 ],
75 'divider1' => [
76 'type' => 'divider'
77 ],
78 'copy' => [
79 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.copy',
80 'iconIdentifier' => 'actions-edit-copy',
81 'callbackAction' => 'copy'
82 ],
83 'copyRelease' => [
84 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.copy',
85 'iconIdentifier' => 'actions-edit-copy-release',
86 'callbackAction' => 'clipboardRelease'
87 ],
88 'cut' => [
89 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.cut',
90 'iconIdentifier' => 'actions-edit-cut',
91 'callbackAction' => 'cut'
92 ],
93 'cutRelease' => [
94 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.cutrelease',
95 'iconIdentifier' => 'actions-edit-cut-release',
96 'callbackAction' => 'clipboardRelease'
97 ],
98 'pasteAfter' => [
99 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.pasteafter',
100 'iconIdentifier' => 'actions-document-paste-after',
101 'callbackAction' => 'pasteAfter'
102 ],
103 'divider2' => [
104 'type' => 'divider'
105 ],
106 'more' => [
107 'type' => 'submenu',
108 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.more',
109 'iconIdentifier' => '',
110 'callbackAction' => 'openSubmenu',
111 'childItems' => [
112 'newWizard' => [
113 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_misc.xlf:CM_newWizard',
114 'iconIdentifier' => 'actions-add',
115 'callbackAction' => 'newContentWizard',
116 ],
117 'openListModule' => [
118 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_misc.xlf:CM_db_list',
119 'iconIdentifier' => 'actions-system-list-open',
120 'callbackAction' => 'openListModule',
121 ],
122 ],
123 ],
124 'divider3' => [
125 'type' => 'divider'
126 ],
127 'enable' => [
128 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_common.xlf:enable',
129 'iconIdentifier' => 'actions-edit-unhide',
130 'callbackAction' => 'enableRecord',
131 ],
132 'disable' => [
133 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_common.xlf:disable',
134 'iconIdentifier' => 'actions-edit-hide',
135 'callbackAction' => 'disableRecord',
136 ],
137 'delete' => [
138 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.delete',
139 'iconIdentifier' => 'actions-edit-delete',
140 'callbackAction' => 'deleteRecord',
141 ],
142 'history' => [
143 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_misc.xlf:CM_history',
144 'iconIdentifier' => 'actions-document-history-open',
145 'callbackAction' => 'openHistoryPopUp',
146 ],
147 ];
148
149 /**
150 * Whether this provider should kick in
151 *
152 * @return bool
153 */
154 public function canHandle(): bool
155 {
156 if (in_array($this->table, ['sys_file', 'sys_filemounts', 'sys_file_storage', 'pages'], true)
157 || strpos($this->table, '-drag') !== false) {
158 return false;
159 }
160 return isset($GLOBALS['TCA'][$this->table]);
161 }
162
163 /**
164 * Initialize db record
165 */
166 protected function initialize()
167 {
168 parent::initialize();
169 $this->record = BackendUtility::getRecordWSOL($this->table, $this->identifier);
170 $this->initPermissions();
171 }
172
173 /**
174 * Priority is set to lower then default value, in order to skip this provider if there is less generic provider available.
175 *
176 * @return int
177 */
178 public function getPriority(): int
179 {
180 return 60;
181 }
182
183 /**
184 * This provider works as a fallback if there is no provider dedicated for certain table, thus it's only kicking in when $items are empty.
185 *
186 * @param array $items
187 * @return array
188 */
189 public function addItems(array $items): array
190 {
191 if (!empty($items)) {
192 return $items;
193 }
194 $this->initialize();
195 return $this->prepareItems($this->itemsConfiguration);
196 }
197
198 /**
199 * Whether a given item can be rendered (e.g. user has enough permissions)
200 *
201 * @param string $itemName
202 * @param string $type
203 * @return bool
204 */
205 protected function canRender(string $itemName, string $type): bool
206 {
207 if (in_array($type, ['divider', 'submenu'], true)) {
208 return true;
209 }
210 if (in_array($itemName, $this->disabledItems, true)) {
211 return false;
212 }
213 $canRender = false;
214 switch ($itemName) {
215 case 'view':
216 $canRender = $this->canBeViewed();
217 break;
218 case 'edit':
219 case 'new':
220 $canRender = $this->canBeEdited();
221 break;
222 case 'newWizard':
223 $canRender = $this->canOpenNewCEWizard();
224 break;
225 case 'info':
226 $canRender = $this->canShowInfo();
227 break;
228 case 'enable':
229 $canRender = $this->canBeEnabled();
230 break;
231 case 'disable':
232 $canRender = $this->canBeDisabled();
233 break;
234 case 'delete':
235 $canRender = $this->canBeDeleted();
236 break;
237 case 'history':
238 $canRender = $this->canShowHistory();
239 break;
240 case 'openListModule':
241 $canRender = $this->canOpenListModule();
242 break;
243 case 'copy':
244 $canRender = $this->canBeCopied();
245 break;
246 case 'copyRelease':
247 $canRender = $this->isRecordInClipboard('copy');
248 break;
249 case 'cut':
250 $canRender = $this->canBeCut();
251 break;
252 case 'cutRelease':
253 $canRender = $this->isRecordInClipboard('cut');
254 break;
255 case 'pasteAfter':
256 $canRender = $this->canBePastedAfter();
257 break;
258 }
259 return $canRender;
260 }
261
262 /**
263 * Saves calculated permissions for a page containing given record, to speed things up
264 */
265 protected function initPermissions()
266 {
267 $this->pageRecord = BackendUtility::getRecord('pages', $this->record['pid']);
268 $this->pagePermissions = $this->backendUser->calcPerms($this->pageRecord);
269 }
270
271 /**
272 * Returns true if a current user have access to given permission
273 *
274 * @see BackendUserAuthentication::doesUserHaveAccess()
275 * @param int $permission
276 * @return bool
277 */
278 protected function hasPagePermission(int $permission): bool
279 {
280 return $this->backendUser->isAdmin() || ($this->pagePermissions & $permission) == $permission;
281 }
282
283 /**
284 * Additional attributes for JS
285 *
286 * @param string $itemName
287 * @return array
288 */
289 protected function getAdditionalAttributes(string $itemName): array
290 {
291 $attributes = [];
292 if ($itemName === 'view') {
293 $attributes += $this->getViewAdditionalAttributes();
294 }
295 if ($itemName === 'newWizard' && $this->table === 'tt_content') {
296 $tsConfig = BackendUtility::getModTSconfig($this->record['pid'], 'mod');
297 $moduleName = isset($tsConfig['properties']['newContentElementWizard.']['override'])
298 ? $tsConfig['properties']['newContentElementWizard.']['override']
299 : 'new_content_element_wizard';
300 $urlParameters = [
301 'id' => $this->record['pid'],
302 'sys_language_uid' => $this->record['sys_language_uid'],
303 'colPos' => $this->record['colPos'],
304 'uid_pid' => -$this->record['uid']
305 ];
306 $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
307 $url = (string)$uriBuilder->buildUriFromRoute($moduleName, $urlParameters);
308 $attributes += [
309 'data-new-wizard-url' => htmlspecialchars($url)
310 ];
311 }
312 if ($itemName === 'delete') {
313 $attributes += $this->getDeleteAdditionalAttributes();
314 }
315 if ($itemName === 'openListModule') {
316 $attributes += [
317 'data-page-uid' => $this->record['pid']
318 ];
319 }
320 if ($itemName === 'pasteAfter') {
321 $attributes += $this->getPasteAdditionalAttributes('after');
322 }
323 return $attributes;
324 }
325
326 /**
327 * Additional attributes for the 'view' item
328 *
329 * @return array
330 */
331 protected function getViewAdditionalAttributes(): array
332 {
333 $attributes = [];
334 $viewLink = $this->getViewLink();
335 if ($viewLink) {
336 $attributes += [
337 'data-preview-url' => htmlspecialchars($viewLink),
338 ];
339 }
340 return $attributes;
341 }
342
343 /**
344 * Additional attributes for the pasteInto and pasteAfter items
345 *
346 * @param string $type "after" or "into"
347 * @return array
348 */
349 protected function getPasteAdditionalAttributes(string $type): array
350 {
351 $attributes = [];
352 if ($this->backendUser->jsConfirmation(JsConfirmation::COPY_MOVE_PASTE)) {
353 $selItem = $this->clipboard->getSelectedRecord();
354 $title = $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_mod_web_list.xlf:clip_paste');
355
356 $confirmMessage = sprintf(
357 $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:mess.'
358 . ($this->clipboard->currentMode() === 'copy' ? 'copy' : 'move') . '_' . $type),
359 GeneralUtility::fixed_lgd_cs($selItem['_RECORD_TITLE'], $this->backendUser->uc['titleLen']),
360 GeneralUtility::fixed_lgd_cs(BackendUtility::getRecordTitle($this->table, $this->record), $this->backendUser->uc['titleLen'])
361 );
362 $attributes += [
363 'data-title' => htmlspecialchars($title),
364 'data-message' => htmlspecialchars($confirmMessage)
365 ];
366 }
367 return $attributes;
368 }
369
370 /**
371 * Additional data for a "delete" action (confirmation modal title and message)
372 *
373 * @return array
374 */
375 protected function getDeleteAdditionalAttributes(): array
376 {
377 $attributes = [];
378 if ($this->backendUser->jsConfirmation(JsConfirmation::DELETE)) {
379 $recordTitle = GeneralUtility::fixed_lgd_cs(BackendUtility::getRecordTitle($this->table, $this->record), $this->backendUser->uc['titleLen']);
380
381 $title = $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_mod_web_list.xlf:delete');
382 $confirmMessage = sprintf(
383 $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:mess.delete'),
384 $recordTitle
385 );
386 $confirmMessage .= BackendUtility::referenceCount(
387 $this->table,
388 $this->record['uid'],
389 ' ' . $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:labels.referencesToRecord')
390 );
391 $confirmMessage .= BackendUtility::translationCount(
392 $this->table,
393 $this->record['uid'],
394 ' ' . $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:labels.translationsOfRecord')
395 );
396 $attributes += [
397 'data-title' => htmlspecialchars($title),
398 'data-message' => htmlspecialchars($confirmMessage)
399 ];
400 }
401 return $attributes;
402 }
403
404 /**
405 * Returns id of the Page used for preview
406 *
407 * @return int
408 */
409 protected function getPreviewPid(): int
410 {
411 return (int)$this->record['pid'];
412 }
413
414 /**
415 * Returns the view link
416 *
417 * @return string
418 */
419 protected function getViewLink(): string
420 {
421 $anchorSection = $this->table === 'tt_content' ? '#c' . $this->record['uid'] : '';
422 $javascriptLink = BackendUtility::viewOnClick(
423 $this->getPreviewPid(),
424 '',
425 null,
426 $anchorSection
427 );
428 $extractedLink = '';
429 if (preg_match('/window\\.open\\(\'([^\']+)\'/i', $javascriptLink, $match)) {
430 // Clean JSON-serialized ampersands ('&')
431 // @see GeneralUtility::quoteJSvalue()
432 $extractedLink = json_decode('"' . trim($match[1], '"') . '"');
433 }
434 return $extractedLink;
435 }
436
437 /**
438 * Checks if the page is allowed to show info
439 *
440 * @return bool
441 */
442 protected function canShowInfo(): bool
443 {
444 return true;
445 }
446
447 /**
448 * Checks if the page is allowed to show info
449 *
450 * @return bool
451 */
452 protected function canShowHistory(): bool
453 {
454 return true;
455 }
456
457 /**
458 * Checks if the record can be previewed in frontend
459 *
460 * @return bool
461 */
462 protected function canBeViewed(): bool
463 {
464 return $this->table === 'tt_content';
465 }
466
467 /**
468 * Whether a record can be edited
469 *
470 * @return bool
471 */
472 protected function canBeEdited(): bool
473 {
474 if (isset($GLOBALS['TCA'][$this->table]['ctrl']['readOnly']) && $GLOBALS['TCA'][$this->table]['ctrl']['readOnly']) {
475 return false;
476 }
477 if ($this->backendUser->isAdmin()) {
478 return true;
479 }
480 if (isset($GLOBALS['TCA'][$this->table]['ctrl']['adminOnly']) && $GLOBALS['TCA'][$this->table]['ctrl']['adminOnly']) {
481 return false;
482 }
483
484 $access = !$this->isRecordLocked()
485 && $this->backendUser->check('tables_modify', $this->table)
486 && $this->hasPagePermission(Permission::CONTENT_EDIT);
487 return $access;
488 }
489
490 /**
491 * Checks if the user has the right to delete the page
492 *
493 * @return bool
494 */
495 protected function canBeDeleted(): bool
496 {
497 $disableDeleteTS = $this->backendUser->getTSConfig('options.disableDelete');
498 $disableDelete = (bool) trim(isset($disableDeleteTS['properties'][$this->table]) ? $disableDeleteTS['properties'][$this->table] : (string)$disableDeleteTS['value']);
499 return !$disableDelete && $this->canBeEdited();
500 }
501
502 /**
503 * Returns true if current record can be unhidden/enabled
504 *
505 * @return bool
506 */
507 protected function canBeEnabled(): bool
508 {
509 return $this->hasDisableColumnWithValue(1) && $this->canBeEdited();
510 }
511
512 /**
513 * Returns true if current record can be hidden
514 *
515 * @return bool
516 */
517 protected function canBeDisabled(): bool
518 {
519 return $this->hasDisableColumnWithValue(0) && $this->canBeEdited();
520 }
521
522 /**
523 * Returns true new content element wizard can be shown
524 *
525 * @return bool
526 */
527 protected function canOpenNewCEWizard(): bool
528 {
529 $tsConfig = BackendUtility::getModTSconfig($this->record['pid'], 'mod.web_layout');
530 $wizardEnabled = true;
531 if (isset($tsConfig['properties']['disableNewContentElementWizard'])) {
532 $wizardEnabled = false;
533 }
534 return $this->table === 'tt_content' && $wizardEnabled && $this->canBeEdited();
535 }
536
537 /**
538 * @return bool
539 */
540 protected function canOpenListModule(): bool
541 {
542 return $this->backendUser->check('modules', 'web_list');
543 }
544
545 /**
546 * @return bool
547 */
548 protected function canBeCopied(): bool
549 {
550 return !$this->isRecordInClipboard('copy')
551 && !$this->isRecordATranslation();
552 }
553
554 /**
555 * @return bool
556 */
557 protected function canBeCut(): bool
558 {
559 return !$this->isRecordInClipboard('cut')
560 && $this->canBeEdited()
561 && !$this->isRecordATranslation();
562 }
563
564 /**
565 * Paste after is only shown for records from the same table (comparing record in clipboard and record clicked)
566 *
567 * @return bool
568 */
569 protected function canBePastedAfter(): bool
570 {
571 $clipboardElementCount = count($this->clipboard->elFromTable($this->table));
572
573 return $clipboardElementCount
574 && $this->backendUser->check('tables_modify', $this->table)
575 && $this->hasPagePermission(Permission::CONTENT_EDIT);
576 }
577
578 /**
579 * Checks if table have "disable" column (e.g. "hidden"), if user has access to this column
580 * and if it contains given value
581 *
582 * @param int $value
583 * @return bool
584 */
585 protected function hasDisableColumnWithValue(int $value): bool
586 {
587 if (isset($GLOBALS['TCA'][$this->table]['ctrl']['enablecolumns']['disabled'])) {
588 $hiddenFieldName = $GLOBALS['TCA'][$this->table]['ctrl']['enablecolumns']['disabled'];
589 if (
590 $hiddenFieldName !== '' && !empty($GLOBALS['TCA'][$this->table]['columns'][$hiddenFieldName]['exclude'])
591 && $this->backendUser->check('non_exclude_fields', $this->table . ':' . $hiddenFieldName)
592 ) {
593 return (int)$this->record[$hiddenFieldName] === (int)$value;
594 }
595 }
596 return false;
597 }
598
599 /**
600 * Record is locked if page is locked or page is not locked but record is
601 *
602 * @return bool
603 */
604 protected function isRecordLocked(): bool
605 {
606 return (int)$this->pageRecord['editlock'] === 1
607 || isset($GLOBALS['TCA'][$this->table]['ctrl']['editlock'])
608 && (int)$this->record[$GLOBALS['TCA'][$this->table]['ctrl']['editlock']] === 1;
609 }
610
611 /**
612 * Returns true is a current record is a delete placeholder
613 *
614 * @return bool
615 */
616 protected function isDeletePlaceholder(): bool
617 {
618 if (!isset($this->record['t3ver_state'])) {
619 return false;
620 }
621 return VersionState::cast($this->record['t3ver_state'])->equals(VersionState::DELETE_PLACEHOLDER);
622 }
623
624 /**
625 * Checks if current record is in the "normal" pad of the clipboard
626 *
627 * @param string $mode "copy", "cut" or '' for any mode
628 * @return bool
629 */
630 protected function isRecordInClipboard(string $mode = ''): bool
631 {
632 $isSelected = '';
633 if ($this->clipboard->current === 'normal') {
634 $isSelected = $this->clipboard->isSelected($this->table, $this->record['uid']);
635 }
636 return $mode === '' ? !empty($isSelected) : $isSelected === $mode;
637 }
638
639 /**
640 * Returns true is a record ia a translation
641 *
642 * @return bool
643 */
644 protected function isRecordATranslation(): bool
645 {
646 return BackendUtility::isTableLocalizable($this->table) && (int)$this->record[$GLOBALS['TCA'][$this->table]['ctrl']['transOrigPointerField']] !== 0;
647 }
648
649 /**
650 * @return string
651 */
652 protected function getIdentifier(): string
653 {
654 return $this->record['uid'];
655 }
656 }