[BUGFIX] Add language parameter to preview url in list module
[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-document-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-document-new',
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-document-new',
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';
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 $additionalParams = '';
420 if ($this->table === 'tt_content') {
421 $language = (int)$this->record[$GLOBALS['TCA']['tt_content']['ctrl']['languageField']];
422 if ($language > 0) {
423 $additionalParams = '&L=' . $language;
424 }
425 }
426 $javascriptLink = BackendUtility::viewOnClick(
427 $this->getPreviewPid(),
428 '',
429 null,
430 '',
431 '',
432 $additionalParams
433 );
434 $extractedLink = '';
435 if (preg_match('/window\\.open\\(\'([^\']+)\'/i', $javascriptLink, $match)) {
436 // Clean JSON-serialized ampersands ('&')
437 // @see GeneralUtility::quoteJSvalue()
438 $extractedLink = json_decode('"' . trim($match[1], '"') . '"');
439 }
440 return $extractedLink;
441 }
442
443 /**
444 * Checks if the page is allowed to show info
445 *
446 * @return bool
447 */
448 protected function canShowInfo(): bool
449 {
450 return true;
451 }
452
453 /**
454 * Checks if the page is allowed to show info
455 *
456 * @return bool
457 */
458 protected function canShowHistory(): bool
459 {
460 return true;
461 }
462
463 /**
464 * Checks if the record can be previewed in frontend
465 *
466 * @return bool
467 */
468 protected function canBeViewed(): bool
469 {
470 return $this->table === 'tt_content';
471 }
472
473 /**
474 * Whether a record can be edited
475 *
476 * @return bool
477 */
478 protected function canBeEdited(): bool
479 {
480 if (isset($GLOBALS['TCA'][$this->table]['ctrl']['readOnly']) && $GLOBALS['TCA'][$this->table]['ctrl']['readOnly']) {
481 return false;
482 }
483 if ($this->backendUser->isAdmin()) {
484 return true;
485 }
486 if (isset($GLOBALS['TCA'][$this->table]['ctrl']['adminOnly']) && $GLOBALS['TCA'][$this->table]['ctrl']['adminOnly']) {
487 return false;
488 }
489
490 $access = !$this->isRecordLocked()
491 && $this->backendUser->check('tables_modify', $this->table)
492 && $this->hasPagePermission(Permission::CONTENT_EDIT);
493 return $access;
494 }
495
496 /**
497 * Checks if disableDelete flag is set in TSConfig for the current table
498 *
499 * @return bool
500 */
501 protected function isDeletionDisabledInTS(): bool
502 {
503 $disableDeleteTS = $this->backendUser->getTSConfig('options.disableDelete');
504 $disableDelete = (bool) trim($disableDeleteTS['properties'][$this->table] ?? (string)$disableDeleteTS['value']);
505 return $disableDelete;
506 }
507
508 /**
509 * Checks if the user has the right to delete the page
510 *
511 * @return bool
512 */
513 protected function canBeDeleted(): bool
514 {
515 return !$this->isDeletionDisabledInTS() && $this->canBeEdited();
516 }
517
518 /**
519 * Returns true if current record can be unhidden/enabled
520 *
521 * @return bool
522 */
523 protected function canBeEnabled(): bool
524 {
525 return $this->hasDisableColumnWithValue(1) && $this->canBeEdited();
526 }
527
528 /**
529 * Returns true if current record can be hidden
530 *
531 * @return bool
532 */
533 protected function canBeDisabled(): bool
534 {
535 return $this->hasDisableColumnWithValue(0) && $this->canBeEdited();
536 }
537
538 /**
539 * Returns true new content element wizard can be shown
540 *
541 * @return bool
542 */
543 protected function canOpenNewCEWizard(): bool
544 {
545 $tsConfig = BackendUtility::getModTSconfig($this->record['pid'], 'mod.web_layout');
546 $wizardEnabled = true;
547 if (isset($tsConfig['properties']['disableNewContentElementWizard'])) {
548 $wizardEnabled = false;
549 }
550 return $this->table === 'tt_content' && $wizardEnabled && $this->canBeEdited();
551 }
552
553 /**
554 * @return bool
555 */
556 protected function canOpenListModule(): bool
557 {
558 return $this->backendUser->check('modules', 'web_list');
559 }
560
561 /**
562 * @return bool
563 */
564 protected function canBeCopied(): bool
565 {
566 return !$this->isRecordInClipboard('copy')
567 && !$this->isRecordATranslation();
568 }
569
570 /**
571 * @return bool
572 */
573 protected function canBeCut(): bool
574 {
575 return !$this->isRecordInClipboard('cut')
576 && $this->canBeEdited()
577 && !$this->isRecordATranslation();
578 }
579
580 /**
581 * Paste after is only shown for records from the same table (comparing record in clipboard and record clicked)
582 *
583 * @return bool
584 */
585 protected function canBePastedAfter(): bool
586 {
587 $clipboardElementCount = count($this->clipboard->elFromTable($this->table));
588
589 return $clipboardElementCount
590 && $this->backendUser->check('tables_modify', $this->table)
591 && $this->hasPagePermission(Permission::CONTENT_EDIT);
592 }
593
594 /**
595 * Checks if table have "disable" column (e.g. "hidden"), if user has access to this column
596 * and if it contains given value
597 *
598 * @param int $value
599 * @return bool
600 */
601 protected function hasDisableColumnWithValue(int $value): bool
602 {
603 if (isset($GLOBALS['TCA'][$this->table]['ctrl']['enablecolumns']['disabled'])) {
604 $hiddenFieldName = $GLOBALS['TCA'][$this->table]['ctrl']['enablecolumns']['disabled'];
605 if (
606 $hiddenFieldName !== '' && !empty($GLOBALS['TCA'][$this->table]['columns'][$hiddenFieldName]['exclude'])
607 && $this->backendUser->check('non_exclude_fields', $this->table . ':' . $hiddenFieldName)
608 ) {
609 return (int)$this->record[$hiddenFieldName] === (int)$value;
610 }
611 }
612 return false;
613 }
614
615 /**
616 * Record is locked if page is locked or page is not locked but record is
617 *
618 * @return bool
619 */
620 protected function isRecordLocked(): bool
621 {
622 return (int)$this->pageRecord['editlock'] === 1
623 || isset($GLOBALS['TCA'][$this->table]['ctrl']['editlock'])
624 && (int)$this->record[$GLOBALS['TCA'][$this->table]['ctrl']['editlock']] === 1;
625 }
626
627 /**
628 * Returns true is a current record is a delete placeholder
629 *
630 * @return bool
631 */
632 protected function isDeletePlaceholder(): bool
633 {
634 if (!isset($this->record['t3ver_state'])) {
635 return false;
636 }
637 return VersionState::cast($this->record['t3ver_state'])->equals(VersionState::DELETE_PLACEHOLDER);
638 }
639
640 /**
641 * Checks if current record is in the "normal" pad of the clipboard
642 *
643 * @param string $mode "copy", "cut" or '' for any mode
644 * @return bool
645 */
646 protected function isRecordInClipboard(string $mode = ''): bool
647 {
648 $isSelected = '';
649 if ($this->clipboard->current === 'normal') {
650 $isSelected = $this->clipboard->isSelected($this->table, $this->record['uid']);
651 }
652 return $mode === '' ? !empty($isSelected) : $isSelected === $mode;
653 }
654
655 /**
656 * Returns true is a record ia a translation
657 *
658 * @return bool
659 */
660 protected function isRecordATranslation(): bool
661 {
662 return BackendUtility::isTableLocalizable($this->table) && (int)$this->record[$GLOBALS['TCA'][$this->table]['ctrl']['transOrigPointerField']] !== 0;
663 }
664
665 /**
666 * @return string
667 */
668 protected function getIdentifier(): string
669 {
670 return $this->record['uid'];
671 }
672 }