Files
cms-backend/Classes/Form/Container/FilesControlContainer.php
T

449 lines
22 KiB
PHP

<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Backend\Form\Container;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Backend\Form\Event\CustomFileControlsEvent;
use TYPO3\CMS\Backend\Form\Event\CustomFileSelectorsEvent;
use TYPO3\CMS\Backend\Form\InlineStackProcessor;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Resource\DefaultUploadFolderResolver;
use TYPO3\CMS\Core\Resource\Filter\FileExtensionFilter;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\OnlineMedia\Helpers\OnlineMediaHelperRegistry;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
/**
* Files entry container.
*
* This container is the entry step to rendering a file reference. It is created by SingleFieldContainer.
*
* The code creates the main structure for the single file reference, initializes the inlineData array,
* that is manipulated and also returned in its manipulated state. The "control" stuff of file
* references is rendered here, for example the "create new" button.
*
* For each existing file reference, a FileReferenceContainer is called for further processing.
*/
class FilesControlContainer extends AbstractContainer
{
private const string FILE_REFERENCE_TABLE = 'sys_file_reference';
/**
* Inline data array used in JS, returned as JSON object to frontend
*/
protected array $fileReferenceData = [];
/**
* @var array<int,JavaScriptModuleInstruction|string|array<string,string>>
*/
protected array $javaScriptModules = [];
protected $defaultFieldWizard = [
'localizationStateSelector' => [
'renderType' => 'localizationStateSelector',
],
];
public function __construct(
private readonly IconFactory $iconFactory,
private readonly InlineStackProcessor $inlineStackProcessor,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly OnlineMediaHelperRegistry $onlineMediaHelperRegistry,
private readonly DefaultUploadFolderResolver $defaultUploadFolderResolver,
private readonly HashService $hashService,
) {}
/**
* Entry method
*
* @return array As defined in initializeResultArray() of AbstractNode
*/
public function render(): array
{
$languageService = $this->getLanguageService();
$this->fileReferenceData = $this->data['inlineData'];
$inlineStructure = $this->data['inlineStructure'];
$table = $this->data['tableName'];
$row = $this->data['databaseRow'];
$field = $this->data['fieldName'];
$parameterArray = $this->data['parameterArray'];
$resultArray = $this->initializeResultArray();
$config = $parameterArray['fieldConf']['config'];
$isReadOnly = (bool)($config['readOnly'] ?? false);
$language = 0;
if ($this->data['tcaSchemata']->has($table) && $this->data['tcaSchemata']->get($table)->hasCapability(TcaSchemaCapability::Language)) {
$languageFieldName = $this->data['tcaSchemata']->get($table)->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName();
$language = isset($row[$languageFieldName][0]) ? (int)$row[$languageFieldName][0] : (int)$row[$languageFieldName];
}
// Add the current inline job to the structure stack
$newStructureItem = [
'table' => $table,
'uid' => $row['uid'],
'field' => $field,
'config' => $config,
];
// Extract FlexForm parts (if any) from element name, e.g. array('vDEF', 'lDEF', 'FlexField', 'vDEF')
$itemName = (string)$parameterArray['itemFormElName'];
if ($itemName !== '') {
$flexFormParts = $this->extractFlexFormParts($itemName);
if ($flexFormParts !== null) {
$newStructureItem['flexform'] = $flexFormParts;
if ($flexFormParts !== []
&& isset($this->data['processedTca']['columns'][$field]['config']['dataStructureIdentifier'])
) {
// Transport the flexform DS identifier fields to the FormFilesAjaxController
$config['dataStructureIdentifier'] = $this->data['processedTca']['columns'][$field]['config']['dataStructureIdentifier'];
}
}
}
$inlineStructure['stable'][] = $newStructureItem;
// Hand over original returnUrl to FormFilesAjaxController. Needed if opening for instance a
// nested element in a new view to then go back to the original returnUrl and not the url of
// the inline ajax controller
$config['originalReturnUrl'] = $this->data['returnUrl'];
// e.g. data[<table>][<uid>][<field>]
$formFieldName = $this->inlineStackProcessor->getFormPrefixFromStructure($inlineStructure);
// e.g. data-<pid>-<table1>-<uid1>-<field1>-<table2>-<uid2>-<field2>
$formFieldIdentifier = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($inlineStructure, $this->data['inlineFirstPid']);
$inlineChildren = $parameterArray['fieldConf']['children'] ?? [];
$config['inline']['first'] = $config['inline']['last'] = false;
if (is_array($inlineChildren) && $inlineChildren !== []) {
$firstChild = array_first($inlineChildren);
if (isset($firstChild['databaseRow']['uid'])) {
$config['inline']['first'] = $firstChild['databaseRow']['uid'];
}
$lastChild = array_last($inlineChildren);
if (isset($lastChild['databaseRow']['uid'])) {
$config['inline']['last'] = $lastChild['databaseRow']['uid'];
}
}
$top = $this->inlineStackProcessor->getStructureLevelFromStructure($inlineStructure, 0);
$this->fileReferenceData['config'][$formFieldIdentifier] = [
'table' => self::FILE_REFERENCE_TABLE,
];
$configJson = (string)json_encode($config);
$this->fileReferenceData['config'][$formFieldIdentifier . '-' . self::FILE_REFERENCE_TABLE] = [
'min' => $config['minitems'] ?? null,
'max' => $config['maxitems'] ?? null,
'sortable' => $config['appearance']['useSortable'] ?? false,
'top' => [
'table' => $top['table'],
'uid' => $top['uid'],
],
'context' => [
'config' => $configJson,
'hmac' => $this->hashService->hmac($configJson, 'FilesContext'),
],
];
$this->fileReferenceData['nested'][$formFieldIdentifier] = $this->data['tabAndInlineStack'];
$resultArray['inlineData'] = $this->fileReferenceData;
// @todo: It might be a good idea to have something like "isLocalizedRecord" or similar set by a data provider
$uidOfDefaultRecord = 0;
if ($this->data['tcaSchemata']->has($table) && $this->data['tcaSchemata']->get($table)->hasCapability(TcaSchemaCapability::Language)) {
$originPointerField = $this->data['tcaSchemata']->get($table)->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName();
$uidOfDefaultRecord = $row[$originPointerField] ?? 0;
}
$isLocalizedParent = $language > 0
&& ($uidOfDefaultRecord[0] ?? $uidOfDefaultRecord) > 0
&& MathUtility::canBeInterpretedAsInteger($row['uid']);
$numberOfFullLocalizedChildren = 0;
$numberOfNotYetLocalizedChildren = 0;
foreach ($inlineChildren as $child) {
if (!$child['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
$numberOfFullLocalizedChildren++;
}
if ($isLocalizedParent && $child['isInlineDefaultLanguageRecordInLocalizedParentContext']) {
$numberOfNotYetLocalizedChildren++;
}
}
if ($isReadOnly || $numberOfFullLocalizedChildren >= ($config['maxitems'] ?? 0)) {
$config['inline']['showNewFileReferenceButton'] = false;
$config['inline']['showCreateNewRelationButton'] = false;
$config['inline']['showOnlineMediaAddButtonStyle'] = false;
}
$fieldInformationResult = $this->renderFieldInformation();
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldInformationResult, false);
$fieldWizardResult = $this->renderFieldWizard();
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fieldWizardResult, false);
$sortableRecordUids = $fileReferencesHtml = [];
foreach ($inlineChildren as $options) {
$options['inlineParentUid'] = $row['uid'];
$options['inlineFirstPid'] = $this->data['inlineFirstPid'];
$options['inlineParentConfig'] = $config;
$options['inlineData'] = $this->fileReferenceData;
$options['inlineStructure'] = $inlineStructure;
$options['inlineExpandCollapseStateArray'] = $this->data['inlineExpandCollapseStateArray'];
$options['renderType'] = 'fileReferenceContainer';
$fileReference = $this->nodeFactory->create($options)->render();
$fileReferencesHtml[] = $fileReference['html'];
$resultArray = $this->mergeChildReturnIntoExistingResult($resultArray, $fileReference, false);
if (!$options['isInlineDefaultLanguageRecordInLocalizedParentContext'] && isset($options['databaseRow']['uid'])) {
// Don't add record to list of "valid" uids if it is only the default
// language record of a not yet localized child
$sortableRecordUids[] = $options['databaseRow']['uid'];
}
}
$view = $this->backendViewFactory->create($this->data['request']);
$view->assignMultiple([
'formFieldIdentifier' => $formFieldIdentifier,
'formFieldName' => $formFieldName,
'webComponentAttributes' => GeneralUtility::implodeAttributes([
'id' => $formFieldIdentifier,
'data-type' => 'file',
'data-object-group' => $formFieldIdentifier . '-' . self::FILE_REFERENCE_TABLE,
'data-form-field' => $formFieldName,
'data-expand-single' => (bool)($config['appearance']['expandSingle'] ?? false) ? 'true' : 'false',
'data-sortable' => (bool)($config['appearance']['useSortable'] ?? false) ? 'true' : 'false',
'data-min' => (int)($config['minitems'] ?? 0),
'data-max' => (int)($config['maxitems'] ?? 0),
], true),
'fieldInformation' => $fieldInformationResult['html'],
'fieldWizard' => $fieldWizardResult['html'],
'fileReferences' => [
'id' => $formFieldIdentifier . '_records',
'title' => $languageService->sL(trim($parameterArray['fieldConf']['label'] ?? '')),
'records' => implode(LF, $fileReferencesHtml),
],
'sortableRecordUids' => implode(',', $sortableRecordUids),
'validationRules' => $this->getValidationDataAsJsonString([
'type' => 'inline',
'minitems' => $config['minitems'] ?? null,
'maxitems' => $config['maxitems'] ?? null,
]),
]);
if (!$isReadOnly && ($config['appearance']['showFileSelectors'] ?? true) !== false) {
/** @var FileExtensionFilter $fileExtensionFilter */
$fileExtensionFilter = GeneralUtility::makeInstance(FileExtensionFilter::class);
$fileExtensionFilter->setAllowedFileExtensions($config['allowed'] ?? null);
$fileExtensionFilter->setDisallowedFileExtensions($config['disallowed'] ?? null);
$view->assign('fileSelectors', $this->getFileSelectors($inlineStructure, $config, $fileExtensionFilter));
$filteredFileExtensions = $fileExtensionFilter->getFilteredFileExtensions();
// Do not display "allowed file extensions" if all extensions are allowed (indicated by ['*'])
if (($filteredFileExtensions['allowedFileExtensions'] ?? null) === ['*']) {
$filteredFileExtensions = [];
}
$view->assignMultiple($filteredFileExtensions);
// Render the localization buttons if needed
if ($numberOfNotYetLocalizedChildren) {
$view->assignMultiple([
'showAllLocalizationLink' => !empty($config['appearance']['showAllLocalizationLink']),
'showSynchronizationLink' => !empty($config['appearance']['showSynchronizationLink']),
]);
}
}
$event = $this->eventDispatcher->dispatch(
new CustomFileControlsEvent($resultArray, $table, $field, $row, $config, $formFieldIdentifier, $formFieldName)
);
$resultArray = $event->getResultArray();
$controls = $event->getControls();
if ($controls !== []) {
$view->assign('customControls', [
'id' => $formFieldIdentifier . '_customControls',
'controls' => implode("\n", $controls),
]);
}
$resultArray['javaScriptModules'] = array_merge(
$resultArray['javaScriptModules'],
$this->javaScriptModules,
[JavaScriptModuleInstruction::create('@typo3/backend/form-engine/container/inline-control-container.js')]
);
$resultArray['html'] = $this->wrapWithFieldsetAndLegend($view->render('Form/FilesControlContainer'));
return $resultArray;
}
/**
* Generate buttons to select, reference and upload files.
*/
protected function getFileSelectors(array $inlineStructure, array $inlineConfiguration, FileExtensionFilter $fileExtensionFilter): array
{
$languageService = $this->getLanguageService();
$backendUser = $this->getBackendUserAuthentication();
$currentStructureDomObjectIdPrefix = $this->inlineStackProcessor->getDomObjectIdPrefixFromStructure($inlineStructure, $this->data['inlineFirstPid']);
$objectPrefix = $currentStructureDomObjectIdPrefix . '-' . self::FILE_REFERENCE_TABLE;
$controls = [];
if ($inlineConfiguration['appearance']['elementBrowserEnabled'] ?? true) {
if ($inlineConfiguration['appearance']['createNewRelationLinkTitle'] ?? false) {
$buttonText = $inlineConfiguration['appearance']['createNewRelationLinkTitle'];
} else {
$buttonText = 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.createNewRelation';
}
$buttonText = $languageService->sL($buttonText);
$attributes = [
'type' => 'button',
'class' => 'btn btn-default t3js-element-browser',
'hidden' => !($inlineConfiguration['inline']['showCreateNewRelationButton'] ?? true) ? 'hidden' : null,
'title' => $buttonText,
'data-mode' => 'file',
'data-allowed-types' => implode(',', $fileExtensionFilter->getAllowedFileExtensions() ?? []),
'data-disallowed-types' => implode(',', $fileExtensionFilter->getDisallowedFileExtensions() ?? []),
'data-irre-object-id' => $objectPrefix,
];
$controls[] = '
<button ' . GeneralUtility::implodeAttributes($attributes, true) . '>
' . $this->iconFactory->getIcon('actions-insert-record', IconSize::SMALL)->render() . '
' . htmlspecialchars($buttonText) . '
</button>';
}
$onlineMediaAllowed = [];
foreach ($this->onlineMediaHelperRegistry->getSupportedFileExtensions() as $supportedFileExtension) {
if ($fileExtensionFilter->isAllowed($supportedFileExtension)) {
$onlineMediaAllowed[] = $supportedFileExtension;
}
}
$showUpload = (bool)($inlineConfiguration['appearance']['fileUploadAllowed'] ?? true);
$showByUrl = ($inlineConfiguration['appearance']['fileByUrlAllowed'] ?? true) && $onlineMediaAllowed !== [];
if (($showUpload || $showByUrl) && $backendUser->getUserSettings()->isUploadFieldsInTopOfEBEnabled()) {
$folder = $this->defaultUploadFolderResolver->resolve(
$backendUser,
$this->data['tableName'] === 'pages' ? $this->data['vanillaUid'] : ($this->data['parentPageRow']['uid'] ?? 0),
$this->data['tableName'],
$this->data['fieldName']
);
if (
$folder instanceof Folder
&& $folder->getStorage()->checkUserActionPermission('add', 'File')
) {
if ($showUpload) {
if ($inlineConfiguration['appearance']['uploadFilesLinkTitle'] ?? false) {
$buttonText = $inlineConfiguration['appearance']['uploadFilesLinkTitle'];
} else {
$buttonText = 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:file_upload.select-and-submit';
}
$buttonText = $languageService->sL($buttonText);
$attributes = [
'type' => 'button',
'class' => 'btn btn-default t3js-drag-uploader',
'title' => $buttonText,
'hidden' => !($inlineConfiguration['inline']['showCreateNewRelationButton'] ?? true) ? 'hidden' : null,
'data-dropzone-target' => '#' . StringUtility::escapeCssSelector($currentStructureDomObjectIdPrefix),
'data-insert-dropzone-before' => '1',
'data-file-irre-object' => $objectPrefix,
'data-file-allowed' => implode(',', $fileExtensionFilter->getAllowedFileExtensions() ?? []),
'data-file-disallowed' => implode(',', $fileExtensionFilter->getDisallowedFileExtensions() ?? []),
'data-target-folder' => $folder->getCombinedIdentifier(),
'data-max-file-size' => (string)(GeneralUtility::getMaxUploadFileSize() * 1024),
];
$controls[] = '
<button ' . GeneralUtility::implodeAttributes($attributes, true) . '>
' . $this->iconFactory->getIcon('actions-upload', IconSize::SMALL)->render() . '
' . htmlspecialchars($buttonText) . '
</button>';
$this->javaScriptModules[] = JavaScriptModuleInstruction::create('@typo3/backend/drag-uploader.js');
}
if ($showByUrl) {
if ($inlineConfiguration['appearance']['addMediaLinkTitle'] ?? false) {
$buttonText = $inlineConfiguration['appearance']['addMediaLinkTitle'];
} else {
$buttonText = 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.new_media.button';
}
$buttonText = $languageService->sL($buttonText);
$attributes = [
'type' => 'button',
'class' => 'btn btn-default t3js-online-media-add-btn',
'title' => $buttonText,
'hidden' => !($inlineConfiguration['inline']['showOnlineMediaAddButtonStyle'] ?? true) ? 'hidden' : null,
'data-target-folder' => $folder->getCombinedIdentifier(),
'data-file-irre-object' => $objectPrefix,
'data-online-media-allowed' => implode(',', $onlineMediaAllowed),
'data-btn-submit' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.new_media.placeholder'),
'data-placeholder' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:online_media.new_media.placeholder'),
'data-online-media-allowed-help-text' => $languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.allowEmbedSources'),
];
// @todo Should be implemented as web component
$controls[] = '
<button ' . GeneralUtility::implodeAttributes($attributes, true) . '>
' . $this->iconFactory->getIcon('actions-online-media-add', IconSize::SMALL)->render() . '
' . htmlspecialchars($buttonText) . '
</button>';
$this->javaScriptModules[] = JavaScriptModuleInstruction::create('@typo3/backend/online-media.js');
}
}
}
$event = $this->eventDispatcher->dispatch(
new CustomFileSelectorsEvent($controls, $this->javaScriptModules, $this->data['tableName'], $this->data['fieldName'], $this->data['databaseRow'], $inlineConfiguration, $fileExtensionFilter, $objectPrefix)
);
$this->javaScriptModules = $event->getJavaScriptModules();
return $event->getSelectors();
}
/**
* Extracts FlexForm parts of a form element name like
* data[table][uid][field][sDEF][lDEF][FlexForm][vDEF]
*/
protected function extractFlexFormParts(string $formElementName): ?array
{
$flexFormParts = null;
$matches = [];
if (preg_match('#^data(?:\[[^]]+\]){3}(\[data\](?:\[[^]]+\]){4,})$#', $formElementName, $matches)) {
$flexFormParts = GeneralUtility::trimExplode(
'][',
trim($matches[1], '[]')
);
}
return $flexFormParts;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}