0992c38012ba5e1bf5c9f2ddb049ae9b79165550
[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\Utility\BackendUtility;
19 use TYPO3\CMS\Core\Type\Bitmask\JsConfirmation;
20 use TYPO3\CMS\Core\Type\Bitmask\Permission;
21 use TYPO3\CMS\Core\Utility\GeneralUtility;
22 use TYPO3\CMS\Core\Versioning\VersionState;
23
24 /**
25 * Class responsible for providing click menu items for db records which don't have custom provider (as e.g. pages)
26 */
27 class RecordProvider extends AbstractProvider
28 {
29 /**
30 * Database record
31 *
32 * @var array
33 */
34 protected $record = [];
35
36 /**
37 * Database record of the page $this->record is placed on
38 *
39 * @var array
40 */
41 protected $pageRecord = [];
42
43 /**
44 * Local cache for the result of BackendUserAuthentication::calcPerms()
45 *
46 * @var int
47 */
48 protected $pagePermissions = 0;
49
50 /**
51 * @var array
52 */
53 protected $itemsConfiguration = [
54 'view' => [
55 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.view',
56 'iconIdentifier' => 'actions-view',
57 'callbackAction' => 'viewRecord'
58 ],
59 'edit' => [
60 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.edit',
61 'iconIdentifier' => 'actions-open',
62 'callbackAction' => 'editRecord'
63 ],
64 'new' => [
65 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.new',
66 'iconIdentifier' => 'actions-add',
67 'callbackAction' => 'newRecord'
68 ],
69 'info' => [
70 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.info',
71 'iconIdentifier' => 'actions-document-info',
72 'callbackAction' => 'openInfoPopUp'
73 ],
74 'divider1' => [
75 'type' => 'divider'
76 ],
77 'copy' => [
78 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.copy',
79 'iconIdentifier' => 'actions-edit-copy',
80 'callbackAction' => 'copy'
81 ],
82 'copyRelease' => [
83 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.copy',
84 'iconIdentifier' => 'actions-edit-copy-release',
85 'callbackAction' => 'clipboardRelease'
86 ],
87 'cut' => [
88 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.cut',
89 'iconIdentifier' => 'actions-edit-cut',
90 'callbackAction' => 'cut'
91 ],
92 'cutRelease' => [
93 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.cutrelease',
94 'iconIdentifier' => 'actions-edit-cut-release',
95 'callbackAction' => 'clipboardRelease'
96 ],
97 'pasteAfter' => [
98 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.pasteafter',
99 'iconIdentifier' => 'actions-document-paste-after',
100 'callbackAction' => 'pasteAfter'
101 ],
102 'divider2' => [
103 'type' => 'divider'
104 ],
105 'more' => [
106 'type' => 'submenu',
107 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.more',
108 'iconIdentifier' => '',
109 'callbackAction' => 'openSubmenu',
110 'childItems' => [
111 'newWizard' => [
112 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_misc.xlf:CM_newWizard',
113 'iconIdentifier' => 'actions-add',
114 'callbackAction' => 'newContentWizard',
115 ],
116 'openListModule' => [
117 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_misc.xlf:CM_db_list',
118 'iconIdentifier' => 'actions-system-list-open',
119 'callbackAction' => 'openListModule',
120 ],
121 ],
122 ],
123 'divider3' => [
124 'type' => 'divider'
125 ],
126 'enable' => [
127 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_common.xlf:enable',
128 'iconIdentifier' => 'actions-edit-unhide',
129 'callbackAction' => 'enableRecord',
130 ],
131 'disable' => [
132 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_common.xlf:disable',
133 'iconIdentifier' => 'actions-edit-hide',
134 'callbackAction' => 'disableRecord',
135 ],
136 'delete' => [
137 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_core.xlf:cm.delete',
138 'iconIdentifier' => 'actions-edit-delete',
139 'callbackAction' => 'deleteRecord',
140 ],
141 'history' => [
142 'label' => 'LLL:EXT:lang/Resources/Private/Language/locallang_misc.xlf:CM_history',
143 'iconIdentifier' => 'actions-document-history-open',
144 'callbackAction' => 'openHistoryPopUp',
145 ],
146 ];
147
148 /**
149 * Whether this provider should kick in
150 *
151 * @return bool
152 */
153 public function canHandle(): bool
154 {
155 if (in_array($this->table, ['sys_file', 'sys_filemounts', 'sys_file_storage', 'pages'], true)
156 || strpos($this->table, '-drag') !== false) {
157 return false;
158 }
159 return isset($GLOBALS['TCA'][$this->table]);
160 }
161
162 /**
163 * Initialize db record
164 */
165 protected function initialize()
166 {
167 parent::initialize();
168 $this->record = BackendUtility::getRecordWSOL($this->table, $this->identifier);
169 $this->initPermissions();
170 }
171
172 /**
173 * Priority is set to lower then default value, in order to skip this provider if there is less generic provider available.
174 *
175 * @return int
176 */
177 public function getPriority(): int
178 {
179 return 60;
180 }
181
182 /**
183 * 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.
184 *
185 * @param array $items
186 * @return array
187 */
188 public function addItems(array $items): array
189 {
190 if (!empty($items)) {
191 return $items;
192 }
193 $this->initialize();
194 return $this->prepareItems($this->itemsConfiguration);
195 }
196
197 /**
198 * Whether a given item can be rendered (e.g. user has enough permissions)
199 *
200 * @param string $itemName
201 * @param string $type
202 * @return bool
203 */
204 protected function canRender(string $itemName, string $type): bool
205 {
206 if (in_array($type, ['divider', 'submenu'], true)) {
207 return true;
208 }
209 if (in_array($itemName, $this->disabledItems, true)) {
210 return false;
211 }
212 $canRender = false;
213 switch ($itemName) {
214 case 'view':
215 $canRender = $this->canBeViewed();
216 break;
217 case 'edit':
218 case 'new':
219 $canRender = $this->canBeEdited();
220 break;
221 case 'newWizard':
222 $canRender = $this->canOpenNewCEWizard();
223 break;
224 case 'info':
225 $canRender = $this->canShowInfo();
226 break;
227 case 'enable':
228 $canRender = $this->canBeEnabled();
229 break;
230 case 'disable':
231 $canRender = $this->canBeDisabled();
232 break;
233 case 'delete':
234 $canRender = $this->canBeDeleted();
235 break;
236 case 'history':
237 $canRender = $this->canShowHistory();
238 break;
239 case 'openListModule':
240 $canRender = $this->canOpenListModule();
241 break;
242 case 'copy':
243 $canRender = $this->canBeCopied();
244 break;
245 case 'copyRelease':
246 $canRender = $this->isRecordInClipboard('copy');
247 break;
248 case 'cut':
249 $canRender = $this->canBeCut();
250 break;
251 case 'cutRelease':
252 $canRender = $this->isRecordInClipboard('cut');
253 break;
254 case 'pasteAfter':
255 $canRender = $this->canBePastedAfter();
256 break;
257 }
258 return $canRender;
259 }
260
261 /**
262 * Saves calculated permissions for a page containing given record, to speed things up
263 */
264 protected function initPermissions()
265 {
266 $this->pageRecord = BackendUtility::getRecord('pages', $this->record['pid']);
267 $this->pagePermissions = $this->backendUser->calcPerms($this->pageRecord);
268 }
269
270 /**
271 * Returns true if a current user have access to given permission
272 *
273 * @see BackendUserAuthentication::doesUserHaveAccess()
274 * @param int $permission
275 * @return bool
276 */
277 protected function hasPagePermission(int $permission): bool
278 {
279 return $this->backendUser->isAdmin() || ($this->pagePermissions & $permission) == $permission;
280 }
281
282 /**
283 * Additional attributes for JS
284 *
285 * @param string $itemName
286 * @return array
287 */
288 protected function getAdditionalAttributes(string $itemName): array
289 {
290 $attributes = [];
291 if ($itemName === 'view') {
292 $attributes += $this->getViewAdditionalAttributes();
293 }
294 if ($itemName === 'newWizard' && $this->table === 'tt_content') {
295 $tsConfig = BackendUtility::getModTSconfig($this->record['pid'], 'mod');
296 $moduleName = isset($tsConfig['properties']['newContentElementWizard.']['override'])
297 ? $tsConfig['properties']['newContentElementWizard.']['override']
298 : 'new_content_element_wizard';
299 $urlParameters = [
300 'id' => $this->record['pid'],
301 'sys_language_uid' => $this->record['sys_language_uid'],
302 'colPos' => $this->record['colPos'],
303 'uid_pid' => -$this->record['uid']
304 ];
305 $url = BackendUtility::getModuleUrl($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(isset($disableDeleteTS['properties'][$this->table]) ? $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 }