[TASK] Use null coalescing operator where possible
[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 = $tsConfig['properties']['newContentElementWizard.']['override'] ?? 'new_content_element_wizard';
298 $urlParameters = [
299 'id' => $this->record['pid'],
300 'sys_language_uid' => $this->record['sys_language_uid'],
301 'colPos' => $this->record['colPos'],
302 'uid_pid' => -$this->record['uid']
303 ];
304 $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
305 $url = (string)$uriBuilder->buildUriFromRoute($moduleName, $urlParameters);
306 $attributes += [
307 'data-new-wizard-url' => htmlspecialchars($url)
308 ];
309 }
310 if ($itemName === 'delete') {
311 $attributes += $this->getDeleteAdditionalAttributes();
312 }
313 if ($itemName === 'openListModule') {
314 $attributes += [
315 'data-page-uid' => $this->record['pid']
316 ];
317 }
318 if ($itemName === 'pasteAfter') {
319 $attributes += $this->getPasteAdditionalAttributes('after');
320 }
321 return $attributes;
322 }
323
324 /**
325 * Additional attributes for the 'view' item
326 *
327 * @return array
328 */
329 protected function getViewAdditionalAttributes(): array
330 {
331 $attributes = [];
332 $viewLink = $this->getViewLink();
333 if ($viewLink) {
334 $attributes += [
335 'data-preview-url' => htmlspecialchars($viewLink),
336 ];
337 }
338 return $attributes;
339 }
340
341 /**
342 * Additional attributes for the pasteInto and pasteAfter items
343 *
344 * @param string $type "after" or "into"
345 * @return array
346 */
347 protected function getPasteAdditionalAttributes(string $type): array
348 {
349 $attributes = [];
350 if ($this->backendUser->jsConfirmation(JsConfirmation::COPY_MOVE_PASTE)) {
351 $selItem = $this->clipboard->getSelectedRecord();
352 $title = $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_mod_web_list.xlf:clip_paste');
353
354 $confirmMessage = sprintf(
355 $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:mess.'
356 . ($this->clipboard->currentMode() === 'copy' ? 'copy' : 'move') . '_' . $type),
357 GeneralUtility::fixed_lgd_cs($selItem['_RECORD_TITLE'], $this->backendUser->uc['titleLen']),
358 GeneralUtility::fixed_lgd_cs(BackendUtility::getRecordTitle($this->table, $this->record), $this->backendUser->uc['titleLen'])
359 );
360 $attributes += [
361 'data-title' => htmlspecialchars($title),
362 'data-message' => htmlspecialchars($confirmMessage)
363 ];
364 }
365 return $attributes;
366 }
367
368 /**
369 * Additional data for a "delete" action (confirmation modal title and message)
370 *
371 * @return array
372 */
373 protected function getDeleteAdditionalAttributes(): array
374 {
375 $attributes = [];
376 if ($this->backendUser->jsConfirmation(JsConfirmation::DELETE)) {
377 $recordTitle = GeneralUtility::fixed_lgd_cs(BackendUtility::getRecordTitle($this->table, $this->record), $this->backendUser->uc['titleLen']);
378
379 $title = $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_mod_web_list.xlf:delete');
380 $confirmMessage = sprintf(
381 $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:mess.delete'),
382 $recordTitle
383 );
384 $confirmMessage .= BackendUtility::referenceCount(
385 $this->table,
386 $this->record['uid'],
387 ' ' . $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:labels.referencesToRecord')
388 );
389 $confirmMessage .= BackendUtility::translationCount(
390 $this->table,
391 $this->record['uid'],
392 ' ' . $this->languageService->sL('LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:labels.translationsOfRecord')
393 );
394 $attributes += [
395 'data-title' => htmlspecialchars($title),
396 'data-message' => htmlspecialchars($confirmMessage)
397 ];
398 }
399 return $attributes;
400 }
401
402 /**
403 * Returns id of the Page used for preview
404 *
405 * @return int
406 */
407 protected function getPreviewPid(): int
408 {
409 return (int)$this->record['pid'];
410 }
411
412 /**
413 * Returns the view link
414 *
415 * @return string
416 */
417 protected function getViewLink(): string
418 {
419 $anchorSection = $this->table === 'tt_content' ? '#c' . $this->record['uid'] : '';
420 $javascriptLink = BackendUtility::viewOnClick(
421 $this->getPreviewPid(),
422 '',
423 null,
424 $anchorSection
425 );
426 $extractedLink = '';
427 if (preg_match('/window\\.open\\(\'([^\']+)\'/i', $javascriptLink, $match)) {
428 // Clean JSON-serialized ampersands ('&')
429 // @see GeneralUtility::quoteJSvalue()
430 $extractedLink = json_decode('"' . trim($match[1], '"') . '"');
431 }
432 return $extractedLink;
433 }
434
435 /**
436 * Checks if the page is allowed to show info
437 *
438 * @return bool
439 */
440 protected function canShowInfo(): bool
441 {
442 return true;
443 }
444
445 /**
446 * Checks if the page is allowed to show info
447 *
448 * @return bool
449 */
450 protected function canShowHistory(): bool
451 {
452 return true;
453 }
454
455 /**
456 * Checks if the record can be previewed in frontend
457 *
458 * @return bool
459 */
460 protected function canBeViewed(): bool
461 {
462 return $this->table === 'tt_content';
463 }
464
465 /**
466 * Whether a record can be edited
467 *
468 * @return bool
469 */
470 protected function canBeEdited(): bool
471 {
472 if (isset($GLOBALS['TCA'][$this->table]['ctrl']['readOnly']) && $GLOBALS['TCA'][$this->table]['ctrl']['readOnly']) {
473 return false;
474 }
475 if ($this->backendUser->isAdmin()) {
476 return true;
477 }
478 if (isset($GLOBALS['TCA'][$this->table]['ctrl']['adminOnly']) && $GLOBALS['TCA'][$this->table]['ctrl']['adminOnly']) {
479 return false;
480 }
481
482 $access = !$this->isRecordLocked()
483 && $this->backendUser->check('tables_modify', $this->table)
484 && $this->hasPagePermission(Permission::CONTENT_EDIT);
485 return $access;
486 }
487
488 /**
489 * Checks if the user has the right to delete the page
490 *
491 * @return bool
492 */
493 protected function canBeDeleted(): bool
494 {
495 $disableDeleteTS = $this->backendUser->getTSConfig('options.disableDelete');
496 $disableDelete = (bool) trim($disableDeleteTS['properties'][$this->table] ?? (string)$disableDeleteTS['value']);
497 return !$disableDelete && $this->canBeEdited();
498 }
499
500 /**
501 * Returns true if current record can be unhidden/enabled
502 *
503 * @return bool
504 */
505 protected function canBeEnabled(): bool
506 {
507 return $this->hasDisableColumnWithValue(1) && $this->canBeEdited();
508 }
509
510 /**
511 * Returns true if current record can be hidden
512 *
513 * @return bool
514 */
515 protected function canBeDisabled(): bool
516 {
517 return $this->hasDisableColumnWithValue(0) && $this->canBeEdited();
518 }
519
520 /**
521 * Returns true new content element wizard can be shown
522 *
523 * @return bool
524 */
525 protected function canOpenNewCEWizard(): bool
526 {
527 $tsConfig = BackendUtility::getModTSconfig($this->record['pid'], 'mod.web_layout');
528 $wizardEnabled = true;
529 if (isset($tsConfig['properties']['disableNewContentElementWizard'])) {
530 $wizardEnabled = false;
531 }
532 return $this->table === 'tt_content' && $wizardEnabled && $this->canBeEdited();
533 }
534
535 /**
536 * @return bool
537 */
538 protected function canOpenListModule(): bool
539 {
540 return $this->backendUser->check('modules', 'web_list');
541 }
542
543 /**
544 * @return bool
545 */
546 protected function canBeCopied(): bool
547 {
548 return !$this->isRecordInClipboard('copy')
549 && !$this->isRecordATranslation();
550 }
551
552 /**
553 * @return bool
554 */
555 protected function canBeCut(): bool
556 {
557 return !$this->isRecordInClipboard('cut')
558 && $this->canBeEdited()
559 && !$this->isRecordATranslation();
560 }
561
562 /**
563 * Paste after is only shown for records from the same table (comparing record in clipboard and record clicked)
564 *
565 * @return bool
566 */
567 protected function canBePastedAfter(): bool
568 {
569 $clipboardElementCount = count($this->clipboard->elFromTable($this->table));
570
571 return $clipboardElementCount
572 && $this->backendUser->check('tables_modify', $this->table)
573 && $this->hasPagePermission(Permission::CONTENT_EDIT);
574 }
575
576 /**
577 * Checks if table have "disable" column (e.g. "hidden"), if user has access to this column
578 * and if it contains given value
579 *
580 * @param int $value
581 * @return bool
582 */
583 protected function hasDisableColumnWithValue(int $value): bool
584 {
585 if (isset($GLOBALS['TCA'][$this->table]['ctrl']['enablecolumns']['disabled'])) {
586 $hiddenFieldName = $GLOBALS['TCA'][$this->table]['ctrl']['enablecolumns']['disabled'];
587 if (
588 $hiddenFieldName !== '' && !empty($GLOBALS['TCA'][$this->table]['columns'][$hiddenFieldName]['exclude'])
589 && $this->backendUser->check('non_exclude_fields', $this->table . ':' . $hiddenFieldName)
590 ) {
591 return (int)$this->record[$hiddenFieldName] === (int)$value;
592 }
593 }
594 return false;
595 }
596
597 /**
598 * Record is locked if page is locked or page is not locked but record is
599 *
600 * @return bool
601 */
602 protected function isRecordLocked(): bool
603 {
604 return (int)$this->pageRecord['editlock'] === 1
605 || isset($GLOBALS['TCA'][$this->table]['ctrl']['editlock'])
606 && (int)$this->record[$GLOBALS['TCA'][$this->table]['ctrl']['editlock']] === 1;
607 }
608
609 /**
610 * Returns true is a current record is a delete placeholder
611 *
612 * @return bool
613 */
614 protected function isDeletePlaceholder(): bool
615 {
616 if (!isset($this->record['t3ver_state'])) {
617 return false;
618 }
619 return VersionState::cast($this->record['t3ver_state'])->equals(VersionState::DELETE_PLACEHOLDER);
620 }
621
622 /**
623 * Checks if current record is in the "normal" pad of the clipboard
624 *
625 * @param string $mode "copy", "cut" or '' for any mode
626 * @return bool
627 */
628 protected function isRecordInClipboard(string $mode = ''): bool
629 {
630 $isSelected = '';
631 if ($this->clipboard->current === 'normal') {
632 $isSelected = $this->clipboard->isSelected($this->table, $this->record['uid']);
633 }
634 return $mode === '' ? !empty($isSelected) : $isSelected === $mode;
635 }
636
637 /**
638 * Returns true is a record ia a translation
639 *
640 * @return bool
641 */
642 protected function isRecordATranslation(): bool
643 {
644 return BackendUtility::isTableLocalizable($this->table) && (int)$this->record[$GLOBALS['TCA'][$this->table]['ctrl']['transOrigPointerField']] !== 0;
645 }
646
647 /**
648 * @return string
649 */
650 protected function getIdentifier(): string
651 {
652 return $this->record['uid'];
653 }
654 }