[!!!][SECURITY] Deny direct FAL commands for form definitions 61/57561/2
authorSusanne Moog <s.moog@neusta.de>
Thu, 12 Jul 2018 09:36:05 +0000 (11:36 +0200)
committerOliver Hader <oliver.hader@typo3.org>
Thu, 12 Jul 2018 09:36:13 +0000 (11:36 +0200)
Before this change, form definitions have been persisted in regular
`.yaml` files. In order to make the meaning and purpose of those
files more explicit, the new file ending `.form.yaml` is introduced.

Invocations of the file abstraction layer API for those form files
have to be allowed explicitly by granting commands individually using
`FilePersistenceSlot::allowInvocation`.

New form definitions are created with the new file ending per default.
An upgrade wizard renames existing form definitions that are stored in
according storage folders (`allowedFileMounts`). In addition references
in FlexForm of content elements are adjusted to the new file names as
well - in case a form definition has been referenced before.

The file list user interface disabled according direct actions for
`.form.yaml` files or redirects those to the according form module.

Using just `.yaml` instead of `.form.yaml` from site packages
is deprecated. Using just `.yaml` instead of `.form.yaml` from
file storages is not allowed anymore.

Resolves: #84910
Releases: master, 8.7
Security-Commit: 444f9dc4f1902871391bd1f139d19b46a63a162f
Security-Bulletin: TYPO3-CORE-SA-2018-003
Change-Id: I456c03f745e614729cdbf2915efc6b5e6d11fc0f
Reviewed-on: https://review.typo3.org/57561
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
Tested-by: Oliver Hader <oliver.hader@typo3.org>
21 files changed:
typo3/sysext/core/Classes/Resource/ResourceStorage.php
typo3/sysext/core/Classes/Utility/File/ExtendedFileUtility.php
typo3/sysext/core/Documentation/Changelog/8.7.x/Important-84910-DenyDirectFALCommandsForFormDefinitions.rst [new file with mode: 0644]
typo3/sysext/form/Classes/Hooks/DataStructureIdentifierHook.php
typo3/sysext/form/Classes/Hooks/FileListEditIconsHook.php [new file with mode: 0644]
typo3/sysext/form/Classes/Hooks/FormFileExtensionUpdate.php [new file with mode: 0644]
typo3/sysext/form/Classes/Hooks/FormFileProvider.php [new file with mode: 0644]
typo3/sysext/form/Classes/Hooks/FormPagePreviewRenderer.php
typo3/sysext/form/Classes/Hooks/ImportExportHook.php [new file with mode: 0644]
typo3/sysext/form/Classes/Mvc/Configuration/YamlSource.php
typo3/sysext/form/Classes/Mvc/Persistence/FormPersistenceManager.php
typo3/sysext/form/Classes/Slot/FilePersistenceSlot.php [new file with mode: 0644]
typo3/sysext/form/Classes/Slot/FormDefinitionPersistenceException.php [new file with mode: 0644]
typo3/sysext/form/Resources/Private/Backend/Templates/FormManager/Index.html
typo3/sysext/form/Resources/Private/Language/Database.xlf
typo3/sysext/form/Tests/Unit/Hooks/DataStructureIdentifierHookTest.php
typo3/sysext/form/Tests/Unit/Mvc/Persistence/Fixtures/BlankForm.form.yaml [new file with mode: 0644]
typo3/sysext/form/Tests/Unit/Mvc/Persistence/Fixtures/BlankForm.yaml [deleted file]
typo3/sysext/form/Tests/Unit/Mvc/Persistence/FormPersistenceManagerTest.php
typo3/sysext/form/ext_localconf.php
typo3/sysext/impexp/Classes/Import.php

index d3e7c73..08a16f5 100644 (file)
@@ -1832,7 +1832,7 @@ class ResourceStorage implements ResourceStorageInterface
                 throw new Exception\ExistingTargetFileNameException('The target file already exists', 1329850997);
             }
         }
-        $this->emitPreFileMoveSignal($file, $targetFolder);
+        $this->emitPreFileMoveSignal($file, $targetFolder, $sanitizedTargetFileName);
         $sourceStorage = $file->getStorage();
         // Call driver method to move the file and update the index entry
         try {
@@ -2505,10 +2505,11 @@ class ResourceStorage implements ResourceStorageInterface
      *
      * @param FileInterface $file
      * @param Folder $targetFolder
+     * @param string $targetFileName
      */
-    protected function emitPreFileMoveSignal(FileInterface $file, Folder $targetFolder)
+    protected function emitPreFileMoveSignal(FileInterface $file, Folder $targetFolder, string $targetFileName)
     {
-        $this->getSignalSlotDispatcher()->dispatch(self::class, self::SIGNAL_PreFileMove, [$file, $targetFolder]);
+        $this->getSignalSlotDispatcher()->dispatch(self::class, self::SIGNAL_PreFileMove, [$file, $targetFolder, $targetFileName]);
     }
 
     /**
index fd8c0dd..8f5432d 100644 (file)
@@ -1014,6 +1014,10 @@ class ExtendedFileUtility extends BasicFileUtility
             $this->writeLog(9, 1, 100, 'File "%s" was not saved! File extension rejected!', [$fileObject->getIdentifier()]);
             $this->addMessageToFlashMessageQueue('FileUtility.FileWasNotSaved', [$fileObject->getIdentifier()]);
             return false;
+        } catch (\RuntimeException $e) {
+            $this->writeLog(9, 1, 100, 'File "%s" was not saved! File extension rejected!', [$fileObject->getIdentifier()]);
+            $this->addMessageToFlashMessageQueue('FileUtility.FileWasNotSaved', [$fileObject->getIdentifier()]);
+            return false;
         }
     }
 
diff --git a/typo3/sysext/core/Documentation/Changelog/8.7.x/Important-84910-DenyDirectFALCommandsForFormDefinitions.rst b/typo3/sysext/core/Documentation/Changelog/8.7.x/Important-84910-DenyDirectFALCommandsForFormDefinitions.rst
new file mode 100644 (file)
index 0000000..dd4e3bd
--- /dev/null
@@ -0,0 +1,53 @@
+.. include:: ../../Includes.txt
+
+=================================================================
+Important: #84910 - Deny direct FAL commands for form definitions
+=================================================================
+
+In order to control settings in user provided form definitions files and only
+allow manipulations by using the backend form editor (or direct file access
+using e.g. SFTP) form file extensions have been changed form simple `.yaml`
+to more specific `.form.yaml`.
+
+Direct file commands by using either the backend file list module or implemented
+invocations of the file abstraction layer (FAL) API are denied per default and
+have to allowed explicitly for the following commands for files ending with the
+new file suffix `.form.yaml`:
+
+* plain command invocations
+  + create (creating new, empty file having `.form.yaml` suffix)
+  + rename (renaming to file having `.form.yaml` suffix)
+  + replace (replacing an existing file having `.form.yaml` suffix)
+  + move (moving to different file having `.form.yaml` suffix)
+* command and content invocations - content signature required
+  + add (uploading new file having `.form.yaml` suffix)
+  + setContents (changing contents of file having `.form.yaml` suffix)
+
+In order to grant those commands, `\TYPO3\CMS\Form\Slot\FilePersistenceSlot`
+has been introduced (singleton instance).
+
+.. code-block:: php
+
+    // Allowing content modifications on a $file object with
+    // given $newContent information prior to executing the command
+
+    $slot = GeneralUtility::makeInstance(FilePersistenceSlot::class);
+    $slot->allowInvocation(
+        FilePersistenceSlot::COMMAND_FILE_SET_CONTENTS,
+        $file->getCombinedIdentifier(),
+        $this->filePersistenceSlot->getContentSignature($newContent)
+    );
+
+    $file->setContents($newContent);
+
+In contrast to *plain command invocations*, those having *content invocations*
+(`add` and `setContents`, see list of commands above) require a content signature
+as well in order to be executed. The previous example demonstrates that for the
+`setContents` command.
+
+Extensions that are modifying (e.g. post-processing) persisted form definition
+files using the file abstraction layer (FAL) API need to adjust and extend their
+implementation and allow according invocations as outlined above.
+
+See :issue:`84910`
+.. index:: Backend, FAL, ext:form
index fa775c5..4c3572b 100644 (file)
@@ -107,11 +107,18 @@ class DataStructureIdentifierHook
                 $formPersistenceManager = GeneralUtility::makeInstance(ObjectManager::class)->get(FormPersistenceManagerInterface::class);
                 $formIsAccessible = false;
                 foreach ($formPersistenceManager->listForms() as $form) {
+                    $invalidFormDefinition = $form['invalid'] ?? false;
+                    $hasDeprecatedFileExtension = $form['deprecatedFileExtension'] ?? false;
+
+                    if ($form['location'] === 'storage' && $hasDeprecatedFileExtension) {
+                        continue;
+                    }
+
                     if ($form['persistenceIdentifier'] === $identifier['ext-form-persistenceIdentifier']) {
                         $formIsAccessible = true;
                     }
 
-                    if (isset($form['invalid']) && $form['invalid']) {
+                    if ($invalidFormDefinition || $hasDeprecatedFileExtension) {
                         $dataStructure['sheets']['sDEF']['ROOT']['el']['settings.persistenceIdentifier']['TCEforms']['config']['items'][] = [
                             $form['name'] . ' (' . $form['persistenceIdentifier'] . ')',
                             $form['persistenceIdentifier'],
diff --git a/typo3/sysext/form/Classes/Hooks/FileListEditIconsHook.php b/typo3/sysext/form/Classes/Hooks/FileListEditIconsHook.php
new file mode 100644 (file)
index 0000000..2bc5fa7
--- /dev/null
@@ -0,0 +1,51 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Form\Hooks;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Utility\StringUtility;
+use TYPO3\CMS\Filelist\FileList;
+use TYPO3\CMS\Filelist\FileListEditIconHookInterface;
+use TYPO3\CMS\Form\Mvc\Persistence\FormPersistenceManager;
+
+/**
+ */
+class FileListEditIconsHook implements FileListEditIconHookInterface
+{
+
+    /**
+     * Modifies edit icon array
+     *
+     * @param array $cells
+     * @param FileList $parentObject
+     */
+    public function manipulateEditIcons(&$cells, &$parentObject)
+    {
+        $fileOrFolderObject = $cells['__fileOrFolderObject'];
+        $fullIdentifier = $fileOrFolderObject->getCombinedIdentifier();
+        $isFormDefinition = StringUtility::endsWith($fullIdentifier, FormPersistenceManager::FORM_DEFINITION_FILE_EXTENSION);
+
+        if (!$isFormDefinition) {
+            return;
+        }
+
+        $disableIconNames = ['edit', 'view', 'replace', 'rename'];
+        foreach ($disableIconNames as $disableIconName) {
+            if (!empty($cells[$disableIconName])) {
+                $cells[$disableIconName] = $parentObject->spaceIcon;
+            }
+        }
+    }
+}
diff --git a/typo3/sysext/form/Classes/Hooks/FormFileExtensionUpdate.php b/typo3/sysext/form/Classes/Hooks/FormFileExtensionUpdate.php
new file mode 100644 (file)
index 0000000..aa73dcd
--- /dev/null
@@ -0,0 +1,390 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Form\Hooks;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Database\Connection;
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Database\ReferenceIndex;
+use TYPO3\CMS\Core\Localization\LanguageService;
+use TYPO3\CMS\Core\Resource\DuplicationBehavior;
+use TYPO3\CMS\Core\Resource\File;
+use TYPO3\CMS\Core\Resource\FolderInterface;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\PathUtility;
+use TYPO3\CMS\Core\Utility\StringUtility;
+use TYPO3\CMS\Extbase\Object\ObjectManager;
+use TYPO3\CMS\Form\Mvc\Configuration\YamlSource;
+use TYPO3\CMS\Form\Mvc\Persistence\FormPersistenceManager;
+use TYPO3\CMS\Form\Slot\FilePersistenceSlot;
+use TYPO3\CMS\Install\Updates\AbstractUpdate;
+
+/**
+ * Update wizard to migrate all forms currently in use to new ending
+ */
+class FormFileExtensionUpdate extends AbstractUpdate
+{
+    /**
+     * @var string
+     */
+    protected $title = 'Rename form definition file extension from .yaml to .form.yaml';
+
+    /**
+     * Checks whether updates are required.
+     *
+     * @param string &$description The description for the update
+     * @return bool Whether an update is required (TRUE) or not (FALSE)
+     */
+    public function checkForUpdate(&$description)
+    {
+        $updateNeeded = false;
+
+        $allStorageFormFiles = $this->getAllStorageFormFilesWithOldNaming();
+        $referencedExtensionFormFiles = $this->groupReferencedExtensionFormFiles(
+            $this->getReferencedFormFilesWithOldNaming()
+        );
+
+        $information = [];
+        if (count($allStorageFormFiles) > 0) {
+            $updateNeeded = true;
+            $information[] = 'Form configuration files were found that should be migrated to be named .form.yaml.';
+        }
+        if (count($referencedExtensionFormFiles) > 0) {
+            $updateNeeded = true;
+            $information[] = 'Referenced extension form configuration files found that should be updated.';
+        }
+        $description = implode('<br>', $information);
+
+        return $updateNeeded;
+    }
+
+    /**
+     * Performs the accordant updates.
+     *
+     * @param array &$dbQueries Queries done in this update
+     * @param string &$customMessage Custom message
+     * @return bool Whether everything went smoothly or not
+     */
+    public function performUpdate(array &$dbQueries, &$customMessage): bool
+    {
+        $messages = [];
+
+        $allStorageFormFiles = $this->getAllStorageFormFilesWithOldNaming();
+        $referencedFormFiles = $this->getReferencedFormFilesWithOldNaming();
+        $referencedExtensionFormFiles = $this->groupReferencedExtensionFormFiles($referencedFormFiles);
+        $filePersistenceSlot = GeneralUtility::makeInstance(FilePersistenceSlot::class);
+        $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
+        $connection = $connectionPool->getConnectionForTable('tt_content');
+        $referenceIndex = GeneralUtility::makeInstance(ReferenceIndex::class);
+        $GLOBALS['LANG'] = GeneralUtility::makeInstance(LanguageService::class);
+        $persistenceManager = $this->getObjectManager()->get(FormPersistenceManager::class);
+
+        $filePersistenceSlot->defineInvocation(
+            FilePersistenceSlot::COMMAND_FILE_RENAME,
+            true
+        );
+
+        // Processing all files in a regular file abstraction layer storage
+        foreach ($allStorageFormFiles as $file) {
+            $oldPersistenceIdentifier = $file->getCombinedIdentifier();
+
+            $newPossiblePersistenceIdentifier = $persistenceManager->getUniquePersistenceIdentifier(
+                $file->getNameWithoutExtension(),
+                $file->getParentFolder()->getCombinedIdentifier()
+            );
+            $newFileName = PathUtility::pathinfo(
+                $newPossiblePersistenceIdentifier,
+                PATHINFO_BASENAME
+            );
+
+            try {
+                $file->rename($newFileName, DuplicationBehavior::RENAME);
+                $newPersistenceIdentifier = $file->getCombinedIdentifier();
+            } catch (\Exception $e) {
+                $messages[] = sprintf(
+                    'Failed to rename identifier "%s" to "%s"',
+                    $oldPersistenceIdentifier,
+                    $newFileName
+                );
+                continue;
+            }
+
+            // Update referenced FlexForm in tt_content elements (if any)
+            $dataItems = $this->filterReferencedFormFilesByIdentifier(
+                $referencedFormFiles,
+                $oldPersistenceIdentifier
+            );
+            if (count($dataItems) === 0) {
+                continue;
+            }
+
+            foreach ($dataItems as $dataItem) {
+                // No reference index update needed since file UID not changed
+                $this->updateContentReference(
+                    $connection,
+                    $dataItem,
+                    $oldPersistenceIdentifier,
+                    $newPersistenceIdentifier
+                );
+            }
+        }
+
+        $filePersistenceSlot->defineInvocation(
+            FilePersistenceSlot::COMMAND_FILE_RENAME,
+            null
+        );
+
+        // Processing all referenced files being part of some extension
+        foreach ($referencedExtensionFormFiles as $identifier => $dataItems) {
+            $oldFilePath = GeneralUtility::getFileAbsFileName(
+                ltrim($identifier, '/')
+            );
+            $newFilePath = $this->upgradeFilename($oldFilePath);
+
+            if (!file_exists($newFilePath)) {
+                $messages[] = sprintf(
+                    'Failed to update content reference of identifier "0:%s"'
+                    . ' (probably not renamed yet using ".form.yaml" suffix)',
+                    $identifier
+                );
+                continue;
+            }
+
+            $oldExtensionIdentifier = preg_replace(
+                '#^/typo3conf/ext/#',
+                'EXT:',
+                $identifier
+            );
+            $newExtensionIdentifier = $this->upgradeFilename(
+                $oldExtensionIdentifier
+            );
+
+            foreach ($dataItems as $dataItem) {
+                $result = $this->updateContentReference(
+                    $connection,
+                    $dataItem,
+                    $oldExtensionIdentifier,
+                    $newExtensionIdentifier
+                );
+                if (!$result) {
+                    continue;
+                }
+                // Update reference index since extension file probably
+                // has been renamed or duplicated without invoking FAL API
+                $referenceIndex->updateRefIndexTable(
+                    'tt_content',
+                    (int)$dataItem['recuid']
+                );
+            }
+        }
+
+        if (count($messages) > 0) {
+            $customMessage = 'The following issues occurred during performing updates:'
+                . '<br><ul><li>' . implode('</li><li>', $messages) . '</li></ul>';
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * @param Connection $connection
+     * @param array $dataItem
+     * @param string $oldIdentifier
+     * @param string $newIdentifier
+     * @return bool
+     */
+    protected function updateContentReference(
+        Connection $connection,
+        array $dataItem,
+        string $oldIdentifier,
+        string $newIdentifier
+    ): bool {
+        if ($oldIdentifier === $newIdentifier) {
+            return false;
+        }
+
+        $flexForm = str_replace(
+            $oldIdentifier,
+            $newIdentifier,
+            $dataItem['pi_flexform']
+        );
+
+        $connection->update(
+            'tt_content',
+            ['pi_flexform' => $flexForm],
+            ['uid' => (int)$dataItem['recuid']]
+        );
+
+        return true;
+    }
+
+    /**
+     * Upgrades filename to end with ".form.yaml", e.g.
+     * + "file.yaml"      -> "file.form.yaml"
+     * + "file.form.yaml" -> "file.form.yaml" (unchanged)
+     *
+     * @param string $filename
+     * @return string
+     */
+    protected function upgradeFilename(string $filename): string
+    {
+        return preg_replace(
+            '#(?<!\.form).yaml$#',
+            '.form.yaml',
+            $filename
+        );
+    }
+
+    /**
+     * @return File[]
+     */
+    protected function getAllStorageFormFilesWithOldNaming(): array
+    {
+        $persistenceManager = $this->getObjectManager()
+            ->get(FormPersistenceManager::class);
+        $yamlSource = $this->getObjectManager()
+            ->get(YamlSource::class);
+
+        return array_filter(
+            $persistenceManager->retrieveYamlFilesFromStorageFolders(),
+            function (File $file) use ($yamlSource) {
+                $isNewFormFile = StringUtility::endsWith(
+                    $file->getName(),
+                    FormPersistenceManager::FORM_DEFINITION_FILE_EXTENSION
+                );
+                if ($isNewFormFile) {
+                    return false;
+                }
+
+                try {
+                    $form = $yamlSource->load([$file]);
+                    return !empty($form['identifier'])
+                        && ($form['type'] ?? null) === 'Form';
+                } catch (\Exception $exception) {
+                }
+                return false;
+            }
+        );
+    }
+
+    /**
+     * @return array
+     */
+    protected function getReferencedFormFilesWithOldNaming(): array
+    {
+        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
+            ->getQueryBuilderForTable('sys_refindex');
+        $queryBuilder->getRestrictions()->removeAll();
+        $records = $queryBuilder
+            ->select(
+                'f.identifier AS identifier',
+                'f.uid AS uid',
+                'f.storage AS storage',
+                'r.recuid AS recuid',
+                't.pi_flexform AS pi_flexform'
+            )
+            ->from('sys_refindex', 'r')
+            ->innerJoin('r', 'sys_file', 'f', 'r.ref_uid = f.uid')
+            ->innerJoin('r', 'tt_content', 't', 'r.recuid = t.uid')
+            ->where(
+                $queryBuilder->expr()->eq(
+                    'r.ref_table',
+                    $queryBuilder->createNamedParameter('sys_file', \PDO::PARAM_STR)
+                ),
+                $queryBuilder->expr()->eq(
+                    'r.softref_key',
+                    $queryBuilder->createNamedParameter('formPersistenceIdentifier', \PDO::PARAM_STR)
+                ),
+                $queryBuilder->expr()->eq(
+                    'r.deleted',
+                    $queryBuilder->createNamedParameter(0, \PDO::PARAM_INT)
+                ),
+                $queryBuilder->expr()->notLike(
+                    'f.identifier',
+                    $queryBuilder->createNamedParameter('%.form.yaml', \PDO::PARAM_STR)
+                )
+            )
+            ->execute()
+            ->fetchAll();
+
+        return $records;
+    }
+
+    /**
+     * @param array $referencedFormFiles
+     * @return array
+     */
+    protected function groupReferencedExtensionFormFiles(
+        array $referencedFormFiles
+    ): array {
+        $referencedExtensionFormFiles = [];
+
+        foreach ($referencedFormFiles as $referencedFormFile) {
+            $identifier = $referencedFormFile['identifier'];
+            if ((int)$referencedFormFile['storage'] !== 0
+                || strpos($identifier, '/typo3conf/ext/') !== 0
+            ) {
+                continue;
+            }
+            $referencedExtensionFormFiles[$identifier][] = $referencedFormFile;
+        }
+
+        return $referencedExtensionFormFiles;
+    }
+
+    /**
+     * @param array $referencedFormFiles
+     * @param string $identifier
+     * @return array
+     */
+    protected function filterReferencedFormFilesByIdentifier(
+        array $referencedFormFiles,
+        string $identifier
+    ): array {
+        return array_filter(
+            $referencedFormFiles,
+            function (array $referencedFormFile) use ($identifier) {
+                $referencedFormFileIdentifier = sprintf(
+                    '%d:%s',
+                    $referencedFormFile['storage'],
+                    $referencedFormFile['identifier']
+                );
+                return $referencedFormFileIdentifier === $identifier;
+            }
+        );
+    }
+
+    /**
+     * @param FolderInterface $folder
+     * @return string
+     */
+    protected function buildCombinedIdentifier(FolderInterface $folder): string
+    {
+        return sprintf(
+            '%d:%s',
+            $folder->getStorage()->getUid(),
+            $folder->getIdentifier()
+        );
+    }
+
+    /**
+     * @return ObjectManager
+     */
+    protected function getObjectManager()
+    {
+        return GeneralUtility::makeInstance(ObjectManager::class);
+    }
+}
diff --git a/typo3/sysext/form/Classes/Hooks/FormFileProvider.php b/typo3/sysext/form/Classes/Hooks/FormFileProvider.php
new file mode 100644 (file)
index 0000000..bd60609
--- /dev/null
@@ -0,0 +1,103 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Form\Hooks;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Utility\StringUtility;
+use TYPO3\CMS\Filelist\ContextMenu\ItemProviders\FileProvider;
+use TYPO3\CMS\Form\Mvc\Persistence\FormPersistenceManager;
+
+/**
+ * Purges previously added form files from items for context menus.
+ */
+class FormFileProvider extends FileProvider
+{
+    /**
+     * @var array
+     */
+    protected $itemsConfiguration = [];
+
+    /**
+     * Lowest priority, thus gets executed last.
+     *
+     * @return int
+     */
+    public function getPriority(): int
+    {
+        return 0;
+    }
+
+    /**
+     * @return bool
+     */
+    public function canHandle(): bool
+    {
+        return parent::canHandle()
+            && StringUtility::endsWith($this->identifier, FormPersistenceManager::FORM_DEFINITION_FILE_EXTENSION);
+    }
+
+    /**
+     * @param array $items
+     * @return array
+     */
+    public function addItems(array $items): array
+    {
+        parent::initialize();
+        return $this->purgeItems($items);
+    }
+
+    /**
+     * Purges items that are not allowed for according command.
+     * According canBeEdited, canBeRenamed, ... commands will always return
+     * false in order to remove those form file items.
+     *
+     * Using the canRender() approach avoid adding hardcoded index name
+     * lookup. Thus, it's streamlined with the rest of the provides, but
+     * actually purges items instead of adding them.
+     *
+     * @param array $items
+     * @return array
+     */
+    protected function purgeItems(array $items): array
+    {
+        foreach ($items as $name => $item) {
+            $type = $item['type'];
+
+            if ($type === 'submenu' && !empty($item['childItems'])) {
+                $item['childItems'] = $this->purgeItems($item['childItems']);
+            } elseif (!parent::canRender($name, $type)) {
+                unset($items[$name]);
+            }
+        }
+
+        return $items;
+    }
+
+    /**
+     * @return bool
+     */
+    protected function canBeEdited(): bool
+    {
+        return false;
+    }
+
+    /**
+     * @return bool
+     */
+    protected function canBeRenamed(): bool
+    {
+        return false;
+    }
+}
index 3b5a4fa..3fe0f5e 100644 (file)
@@ -22,11 +22,13 @@ use TYPO3\CMS\Core\Messaging\AbstractMessage;
 use TYPO3\CMS\Core\Messaging\FlashMessage;
 use TYPO3\CMS\Core\Messaging\FlashMessageService;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\StringUtility;
 use TYPO3\CMS\Extbase\Object\ObjectManager;
 use TYPO3\CMS\Extbase\Service\FlexFormService;
 use TYPO3\CMS\Form\Mvc\Configuration\Exception\NoSuchFileException;
 use TYPO3\CMS\Form\Mvc\Configuration\Exception\ParseErrorException;
 use TYPO3\CMS\Form\Mvc\Persistence\Exception\PersistenceManagerException;
+use TYPO3\CMS\Form\Mvc\Persistence\FormPersistenceManager;
 use TYPO3\CMS\Form\Mvc\Persistence\FormPersistenceManagerInterface;
 
 /**
@@ -68,8 +70,18 @@ class FormPagePreviewRenderer implements PageLayoutViewDrawItemHookInterface
                     $formPersistenceManager = GeneralUtility::makeInstance(ObjectManager::class)->get(FormPersistenceManagerInterface::class);
 
                     try {
-                        $formDefinition = $formPersistenceManager->load($persistenceIdentifier);
-                        $formLabel = $formDefinition['label'];
+                        if (
+                            StringUtility::endsWith($persistenceIdentifier, FormPersistenceManager::FORM_DEFINITION_FILE_EXTENSION)
+                            || strpos($persistenceIdentifier, 'EXT:') === 0
+                        ) {
+                            $formDefinition = $formPersistenceManager->load($persistenceIdentifier);
+                            $formLabel = $formDefinition['label'];
+                        } else {
+                            $formLabel = sprintf(
+                                $this->getLanguageService()->sL(self::L10N_PREFIX . 'tt_content.preview.inaccessiblePersistenceIdentifier'),
+                                $persistenceIdentifier
+                            );
+                        }
                     } catch (ParseErrorException $e) {
                         $formLabel = sprintf(
                             $this->getLanguageService()->sL(self::L10N_PREFIX . 'tt_content.preview.invalidPersistenceIdentifier'),
diff --git a/typo3/sysext/form/Classes/Hooks/ImportExportHook.php b/typo3/sysext/form/Classes/Hooks/ImportExportHook.php
new file mode 100644 (file)
index 0000000..ccebe43
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+namespace TYPO3\CMS\Form\Hooks;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Form\Slot\FilePersistenceSlot;
+
+/**
+ * @internal
+ */
+class ImportExportHook
+{
+
+    /**
+     * @param array $params
+     */
+    public function beforeAddSysFileRecordOnImport(array $params): void
+    {
+        $fileRecord = $params['fileRecord'];
+        $temporaryFile = $params['temporaryFile'];
+
+        $formPersistenceSlot = GeneralUtility::makeInstance(FilePersistenceSlot::class);
+        $formPersistenceSlot->allowInvocation(
+            FilePersistenceSlot::COMMAND_FILE_ADD,
+            implode(':', [$fileRecord['storage'], $fileRecord['identifier']]),
+            $formPersistenceSlot->getContentSignature(file_get_contents($temporaryFile))
+        );
+    }
+}
index f2ea6d3..9105fcf 100644 (file)
@@ -21,11 +21,13 @@ use Symfony\Component\Yaml\Exception\ParseException;
 use Symfony\Component\Yaml\Yaml;
 use TYPO3\CMS\Core\Resource\Exception\InsufficientFileAccessPermissionsException;
 use TYPO3\CMS\Core\Resource\File;
+use TYPO3\CMS\Core\Resource\FolderInterface;
 use TYPO3\CMS\Core\Utility\ArrayUtility;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Form\Mvc\Configuration\Exception\FileWriteException;
 use TYPO3\CMS\Form\Mvc\Configuration\Exception\NoSuchFileException;
 use TYPO3\CMS\Form\Mvc\Configuration\Exception\ParseErrorException;
+use TYPO3\CMS\Form\Slot\FilePersistenceSlot;
 
 /**
  * Configuration source based on YAML files
@@ -45,6 +47,11 @@ class YamlSource
     protected $usePhpYamlExtension = false;
 
     /**
+     * @var FilePersistenceSlot
+     */
+    protected $filePersistenceSlot;
+
+    /**
      * Use PHP YAML Extension if installed.
      * @internal
      */
@@ -56,6 +63,14 @@ class YamlSource
     }
 
     /**
+     * @param FilePersistenceSlot $filePersistenceSlot
+     */
+    public function injectFilePersistenceSlot(FilePersistenceSlot $filePersistenceSlot)
+    {
+        $this->filePersistenceSlot = $filePersistenceSlot;
+    }
+
+    /**
      * Loads the specified configuration files and returns its merged content
      * as an array.
      *
@@ -139,6 +154,16 @@ class YamlSource
 
         if ($fileToSave instanceof File) {
             try {
+                $this->filePersistenceSlot->allowInvocation(
+                    FilePersistenceSlot::COMMAND_FILE_SET_CONTENTS,
+                    $this->buildCombinedIdentifier(
+                        $fileToSave->getParentFolder(),
+                        $fileToSave->getName()
+                    ),
+                    $this->filePersistenceSlot->getContentSignature(
+                        $header . LF . $yaml
+                    )
+                );
                 $fileToSave->setContents($header . LF . $yaml);
             } catch (InsufficientFileAccessPermissionsException $e) {
                 throw new FileWriteException($e->getMessage(), 1512582753, $e);
@@ -182,4 +207,19 @@ class YamlSource
         }
         return $header;
     }
+
+    /**
+     * @param FolderInterface $folder
+     * @param string $fileName
+     * @return string
+     */
+    protected function buildCombinedIdentifier(FolderInterface $folder, string $fileName): string
+    {
+        return sprintf(
+            '%d:%s%s',
+            $folder->getStorage()->getUid(),
+            $folder->getIdentifier(),
+            $fileName
+        );
+    }
 }
index c25499b..49bdb9b 100644 (file)
@@ -25,12 +25,14 @@ use TYPO3\CMS\Core\Resource\Folder;
 use TYPO3\CMS\Core\Resource\ResourceStorage;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Core\Utility\PathUtility;
+use TYPO3\CMS\Core\Utility\StringUtility;
 use TYPO3\CMS\Extbase\Object\ObjectManager;
 use TYPO3\CMS\Form\Mvc\Configuration\ConfigurationManagerInterface;
 use TYPO3\CMS\Form\Mvc\Configuration\Exception\FileWriteException;
 use TYPO3\CMS\Form\Mvc\Persistence\Exception\NoUniqueIdentifierException;
 use TYPO3\CMS\Form\Mvc\Persistence\Exception\NoUniquePersistenceIdentifierException;
 use TYPO3\CMS\Form\Mvc\Persistence\Exception\PersistenceManagerException;
+use TYPO3\CMS\Form\Slot\FilePersistenceSlot;
 
 /**
  * Concrete implementation of the FormPersistenceManagerInterface
@@ -39,6 +41,7 @@ use TYPO3\CMS\Form\Mvc\Persistence\Exception\PersistenceManagerException;
  */
 class FormPersistenceManager implements FormPersistenceManagerInterface
 {
+    const FORM_DEFINITION_FILE_EXTENSION = '.form.yaml';
 
     /**
      * @var \TYPO3\CMS\Form\Mvc\Configuration\YamlSource
@@ -56,6 +59,11 @@ class FormPersistenceManager implements FormPersistenceManagerInterface
     protected $formSettings;
 
     /**
+     * @var FilePersistenceSlot
+     */
+    protected $filePersistenceSlot;
+
+    /**
      * @param \TYPO3\CMS\Form\Mvc\Configuration\YamlSource $yamlSource
      * @internal
      */
@@ -74,6 +82,14 @@ class FormPersistenceManager implements FormPersistenceManagerInterface
     }
 
     /**
+     * @param FilePersistenceSlot $filePersistenceSlot
+     */
+    public function injectFilePersistenceSlot(FilePersistenceSlot $filePersistenceSlot)
+    {
+        $this->filePersistenceSlot = $filePersistenceSlot;
+    }
+
+    /**
      * @internal
      */
     public function initializeObject()
@@ -85,7 +101,9 @@ class FormPersistenceManager implements FormPersistenceManagerInterface
 
     /**
      * Load the array formDefinition identified by $persistenceIdentifier, and return it.
-     * Only files with the extension .yaml are loaded.
+     * Only files with the extension .yaml or .form.yaml are loaded.
+     * Form definition file names which not ends with ".form.yaml" has been
+     * deprecated in v9 and will not be supported in v10.
      *
      * @param string $persistenceIdentifier
      * @return array
@@ -109,15 +127,28 @@ class FormPersistenceManager implements FormPersistenceManagerInterface
 
         try {
             $yaml = $this->yamlSource->load([$file]);
+
+            if (isset($yaml['identifier'], $yaml['type']) && $yaml['type'] === 'Form') {
+                if (
+                    !$this->hasValidFileExtension($persistenceIdentifier)
+                    && strpos($persistenceIdentifier, 'EXT:') === 0
+                ) {
+                    trigger_error(
+                        'Form definition file name ("' . $persistenceIdentifier . '") which does not end with ".form.yaml" has been deprecated in v9 and will not be supported in v10.',
+                        E_USER_DEPRECATED
+                    );
+                } elseif (
+                    !$this->hasValidFileExtension($persistenceIdentifier)
+                    && strpos($persistenceIdentifier, 'EXT:') !== 0
+                ) {
+                    throw new PersistenceManagerException(sprintf('Form definition "%s" does not end with ".form.yaml".', $persistenceIdentifier), 1531160649);
+                }
+            }
         } catch (\Exception $e) {
             $yaml = [
                 'type' => 'Form',
-                'identifier' => $file->getCombinedIdentifier(),
+                'identifier' => $persistenceIdentifier,
                 'label' => $e->getMessage(),
-                'error' => [
-                    'code' => $e->getCode(),
-                    'message' => $e->getMessage()
-                ],
                 'invalid' => true,
             ];
         }
@@ -127,7 +158,7 @@ class FormPersistenceManager implements FormPersistenceManagerInterface
 
     /**
      * Save the array form representation identified by $persistenceIdentifier.
-     * Only files with the extension .yaml are saved.
+     * Only files with the extension .form.yaml are saved.
      * If the formDefinition is located within a EXT: resource, save is only
      * allowed if the configuration path
      * TYPO3.CMS.Form.persistenceManager.allowSaveToExtensionPaths
@@ -140,7 +171,7 @@ class FormPersistenceManager implements FormPersistenceManagerInterface
      */
     public function save(string $persistenceIdentifier, array $formDefinition)
     {
-        if (pathinfo($persistenceIdentifier, PATHINFO_EXTENSION) !== 'yaml') {
+        if (!$this->hasValidFileExtension($persistenceIdentifier)) {
             throw new PersistenceManagerException(sprintf('The file "%s" could not be saved.', $persistenceIdentifier), 1477679820);
         }
 
@@ -169,7 +200,7 @@ class FormPersistenceManager implements FormPersistenceManagerInterface
 
     /**
      * Delete the form representation identified by $persistenceIdentifier.
-     * Only files with the extension .yaml are removed.
+     * Only files with the extension .form.yaml are removed.
      *
      * @param string $persistenceIdentifier
      * @throws PersistenceManagerException
@@ -177,7 +208,7 @@ class FormPersistenceManager implements FormPersistenceManagerInterface
      */
     public function delete(string $persistenceIdentifier)
     {
-        if (pathinfo($persistenceIdentifier, PATHINFO_EXTENSION) !== 'yaml') {
+        if (!$this->hasValidFileExtension($persistenceIdentifier)) {
             throw new PersistenceManagerException(sprintf('The file "%s" could not be removed.', $persistenceIdentifier), 1472239534);
         }
         if (!$this->exists($persistenceIdentifier)) {
@@ -213,7 +244,7 @@ class FormPersistenceManager implements FormPersistenceManagerInterface
     public function exists(string $persistenceIdentifier): bool
     {
         $exists = false;
-        if (pathinfo($persistenceIdentifier, PATHINFO_EXTENSION) === 'yaml') {
+        if ($this->hasValidFileExtension($persistenceIdentifier)) {
             if (strpos($persistenceIdentifier, 'EXT:') === 0) {
                 if (array_key_exists(pathinfo($persistenceIdentifier, PATHINFO_DIRNAME) . '/', $this->getAccessibleExtensionFolders())) {
                     $exists = file_exists(GeneralUtility::getFileAbsFileName($persistenceIdentifier));
@@ -239,43 +270,47 @@ class FormPersistenceManager implements FormPersistenceManagerInterface
      */
     public function listForms(): array
     {
-        $fileExtensionFilter = GeneralUtility::makeInstance(FileExtensionFilter::class);
-        $fileExtensionFilter->setAllowedFileExtensions(['yaml']);
-
         $identifiers = [];
         $forms = [];
-        /** @var \TYPO3\CMS\Core\Resource\Folder $folder */
-        foreach ($this->getAccessibleFormStorageFolders() as $folder) {
-            $storage = $folder->getStorage();
-            $storage->addFileAndFolderNameFilter([$fileExtensionFilter, 'filterFileList']);
 
+        foreach ($this->retrieveYamlFilesFromStorageFolders() as $file) {
+            /** @var Folder $folder */
+            $folder = $file->getParentFolder();
             // TODO: deprecated since TYPO3 v9, will be removed in TYPO3 v10
-            $formReadOnly = false;
-            if ($folder->getCombinedIdentifier() === '1:/user_upload/') {
-                $formReadOnly = true;
-            }
+            $formReadOnly = $folder->getCombinedIdentifier() === '1:/user_upload/';
 
-            $files = $folder->getFiles(0, 0, Folder::FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS, true);
-            foreach ($files as $file) {
-                $persistenceIdentifier = $storage->getUid() . ':' . $file->getIdentifier();
+            $persistenceIdentifier = $file->getCombinedIdentifier();
 
-                $form = $this->load($persistenceIdentifier);
-                if (isset($form['identifier'], $form['type']) && $form['type'] === 'Form') {
-                    $forms[] = [
-                        'identifier' => $form['identifier'],
-                        'name' => $form['label'] ?? $form['identifier'],
-                        'persistenceIdentifier' => $persistenceIdentifier,
-                        'readOnly' => $formReadOnly,
-                        'removable' => true,
-                        'location' => 'storage',
-                        'duplicateIdentifier' => false,
-                        'invalid' => $form['invalid'],
-                        'error' => $form['error'],
-                    ];
-                    $identifiers[$form['identifier']]++;
-                }
+            $form = $this->load($persistenceIdentifier);
+            if (empty($form['identifier']) || ($form['type'] ?? null) !== 'Form') {
+                continue;
+            }
+
+            if ($this->hasValidFileExtension($persistenceIdentifier)) {
+                $forms[] = [
+                    'identifier' => $form['identifier'],
+                    'name' => $form['label'] ?? $form['identifier'],
+                    'persistenceIdentifier' => $persistenceIdentifier,
+                    'readOnly' => $formReadOnly,
+                    'removable' => true,
+                    'location' => 'storage',
+                    'duplicateIdentifier' => false,
+                    'invalid' => $form['invalid'],
+                ];
+                $identifiers[$form['identifier']]++;
+            } else {
+                $forms[] = [
+                    'identifier' => $form['identifier'],
+                    'name' => $form['label'] ?? $form['identifier'],
+                    'persistenceIdentifier' => $persistenceIdentifier,
+                    'readOnly' => true,
+                    'removable' => false,
+                    'location' => 'storage',
+                    'duplicateIdentifier' => false,
+                    'invalid' => false,
+                    'deprecatedFileExtension' => true,
+                ];
             }
-            $storage->resetFileAndFolderNameFiltersToDefault();
         }
 
         foreach ($this->getAccessibleExtensionFolders() as $relativePath => $fullPath) {
@@ -286,18 +321,31 @@ class FormPersistenceManager implements FormPersistenceManagerInterface
                 }
                 $form = $this->load($relativePath . $fileInfo->getFilename());
                 if (isset($form['identifier'], $form['type']) && $form['type'] === 'Form') {
-                    $forms[] = [
-                        'identifier' => $form['identifier'],
-                        'name' => $form['label'] ?? $form['identifier'],
-                        'persistenceIdentifier' => $relativePath . $fileInfo->getFilename(),
-                        'readOnly' => $this->formSettings['persistenceManager']['allowSaveToExtensionPaths'] ? false: true,
-                        'removable' => $this->formSettings['persistenceManager']['allowDeleteFromExtensionPaths'] ? true: false,
-                        'location' => 'extension',
-                        'duplicateIdentifier' => false,
-                        'invalid' => $form['invalid'],
-                        'error' => $form['error'],
-                    ];
-                    $identifiers[$form['identifier']]++;
+                    if ($this->hasValidFileExtension($fileInfo->getFilename())) {
+                        $forms[] = [
+                            'identifier' => $form['identifier'],
+                            'name' => $form['label'] ?? $form['identifier'],
+                            'persistenceIdentifier' => $relativePath . $fileInfo->getFilename(),
+                            'readOnly' => $this->formSettings['persistenceManager']['allowSaveToExtensionPaths'] ? false: true,
+                            'removable' => $this->formSettings['persistenceManager']['allowDeleteFromExtensionPaths'] ? true: false,
+                            'location' => 'extension',
+                            'duplicateIdentifier' => false,
+                            'invalid' => $form['invalid'],
+                        ];
+                        $identifiers[$form['identifier']]++;
+                    } else {
+                        $forms[] = [
+                            'identifier' => $form['identifier'],
+                            'name' => $form['label'] ?? $form['identifier'],
+                            'persistenceIdentifier' => $relativePath . $fileInfo->getFilename(),
+                            'readOnly' => true,
+                            'removable' => false,
+                            'location' => 'extension',
+                            'duplicateIdentifier' => false,
+                            'invalid' => false,
+                            'deprecatedFileExtension' => true,
+                        ];
+                    }
                 }
             }
         }
@@ -316,6 +364,40 @@ class FormPersistenceManager implements FormPersistenceManagerInterface
     }
 
     /**
+     * Retrieves yaml files from storage folders for further processing.
+     * At this time it's not determined yet, whether these files contain form data.
+     *
+     * @return File[]
+     * @internal
+     */
+    public function retrieveYamlFilesFromStorageFolders(): array
+    {
+        $filesFromStorageFolders = [];
+
+        $fileExtensionFilter = GeneralUtility::makeInstance(FileExtensionFilter::class);
+        $fileExtensionFilter->setAllowedFileExtensions(['yaml']);
+
+        foreach ($this->getAccessibleFormStorageFolders() as $folder) {
+            $storage = $folder->getStorage();
+            $storage->addFileAndFolderNameFilter([
+                $fileExtensionFilter,
+                'filterFileList'
+            ]);
+
+            $files = $folder->getFiles(
+                0,
+                0,
+                Folder::FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS,
+                true
+            );
+            $filesFromStorageFolders = $filesFromStorageFolders + $files;
+            $storage->resetFileAndFolderNameFiltersToDefault();
+        }
+
+        return $filesFromStorageFolders;
+    }
+
+    /**
      * Return a list of all accessible file mountpoints for the
      * current backend user.
      *
@@ -410,17 +492,17 @@ class FormPersistenceManager implements FormPersistenceManagerInterface
     public function getUniquePersistenceIdentifier(string $formIdentifier, string $savePath): string
     {
         $savePath = rtrim($savePath, '/') . '/';
-        $formPersistenceIdentifier = $savePath . $formIdentifier . '.yaml';
+        $formPersistenceIdentifier = $savePath . $formIdentifier . self::FORM_DEFINITION_FILE_EXTENSION;
         if (!$this->exists($formPersistenceIdentifier)) {
             return $formPersistenceIdentifier;
         }
         for ($attempts = 1; $attempts < 100; $attempts++) {
-            $formPersistenceIdentifier = $savePath . sprintf('%s_%d', $formIdentifier, $attempts) . '.yaml';
+            $formPersistenceIdentifier = $savePath . sprintf('%s_%d', $formIdentifier, $attempts) . self::FORM_DEFINITION_FILE_EXTENSION;
             if (!$this->exists($formPersistenceIdentifier)) {
                 return $formPersistenceIdentifier;
             }
         }
-        $formPersistenceIdentifier = $savePath . sprintf('%s_%d', $formIdentifier, time()) . '.yaml';
+        $formPersistenceIdentifier = $savePath . sprintf('%s_%d', $formIdentifier, time()) . self::FORM_DEFINITION_FILE_EXTENSION;
         if (!$this->exists($formPersistenceIdentifier)) {
             return $formPersistenceIdentifier;
         }
@@ -529,6 +611,10 @@ class FormPersistenceManager implements FormPersistenceManagerInterface
         }
 
         if (!$storage->hasFile($fileIdentifier)) {
+            $this->filePersistenceSlot->allowInvocation(
+                FilePersistenceSlot::COMMAND_FILE_CREATE,
+                $folder->getCombinedIdentifier() . $pathinfo['basename']
+            );
             $file = $folder->createFile($pathinfo['basename']);
         } else {
             $file = $storage->getFile($fileIdentifier);
@@ -554,4 +640,13 @@ class FormPersistenceManager implements FormPersistenceManagerInterface
         }
         return $storage;
     }
+
+    /**
+     * @param string $fileName
+     * @return bool
+     */
+    protected function hasValidFileExtension(string $fileName): bool
+    {
+        return StringUtility::endsWith($fileName, self::FORM_DEFINITION_FILE_EXTENSION);
+    }
 }
diff --git a/typo3/sysext/form/Classes/Slot/FilePersistenceSlot.php b/typo3/sysext/form/Classes/Slot/FilePersistenceSlot.php
new file mode 100644 (file)
index 0000000..7523dee
--- /dev/null
@@ -0,0 +1,307 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Form\Slot;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Resource\FileInterface;
+use TYPO3\CMS\Core\Resource\FolderInterface;
+use TYPO3\CMS\Core\SingletonInterface;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\StringUtility;
+use TYPO3\CMS\Form\Mvc\Persistence\FormPersistenceManager;
+
+/**
+ * @internal
+ */
+class FilePersistenceSlot implements SingletonInterface
+{
+    const COMMAND_FILE_ADD = 'fileAdd';
+    const COMMAND_FILE_CREATE = 'fileCreate';
+    const COMMAND_FILE_MOVE = 'fileMove';
+    const COMMAND_FILE_RENAME = 'fileRename';
+    const COMMAND_FILE_REPLACE = 'fileReplace';
+    const COMMAND_FILE_SET_CONTENTS = 'fileSetContents';
+
+    /**
+     * @var array
+     */
+    protected $definedInvocations = [];
+
+    /**
+     * @var array
+     */
+    protected $allowedInvocations = [];
+
+    /**
+     * @param string $content
+     * @return string
+     */
+    public function getContentSignature(string $content): string
+    {
+        return GeneralUtility::hmac($content);
+    }
+
+    /**
+     * Defines invocations on command level only depending on the type:
+     *
+     * + true: whitelist command, takes precedence over $allowedInvocations
+     * + false: blacklist command, takes precedence over $allowedInvocations
+     * + removes previously definition for particular command
+     *
+     * @param string $command
+     * @param bool|null $type
+     */
+    public function defineInvocation(string $command, bool $type = null)
+    {
+        $this->definedInvocations[$command] = $type;
+        if ($type === null) {
+            unset($this->definedInvocations[$command]);
+        }
+    }
+
+    /**
+     * Allows invocation for a particular combination of command and file
+     * identifier. Commands providing new content have have to submit a HMAC
+     * signature on the content as well.
+     *
+     * @param string $command
+     * @param string $combinedFileIdentifier
+     * @param string $contentSignature
+     * @return bool
+     * @see getContentSignature
+     */
+    public function allowInvocation(
+        string $command,
+        string $combinedFileIdentifier,
+        string $contentSignature = null
+    ): bool {
+        $index = $this->searchAllowedInvocation(
+            $command,
+            $combinedFileIdentifier,
+            $contentSignature
+        );
+
+        if ($index !== null) {
+            return false;
+        }
+
+        $this->allowedInvocations[] = [
+            'command' => $command,
+            'combinedFileIdentifier' => $combinedFileIdentifier,
+            'contentSignature' => $contentSignature,
+        ];
+
+        return true;
+    }
+
+    /**
+     * @param string $fileName
+     * @param FolderInterface $targetFolder
+     */
+    public function onPreFileCreate(string $fileName, FolderInterface $targetFolder): void
+    {
+        $combinedFileIdentifier = $this->buildCombinedIdentifier(
+            $targetFolder,
+            $fileName
+        );
+
+        $this->assertFileName(
+            self::COMMAND_FILE_CREATE,
+            $combinedFileIdentifier
+        );
+    }
+
+    /**
+     * @param string $targetFileName
+     * @param FolderInterface $targetFolder
+     * @param string $sourceFilePath
+     */
+    public function onPreFileAdd(
+        string $targetFileName,
+        FolderInterface $targetFolder,
+        string $sourceFilePath
+    ): void {
+        $combinedFileIdentifier = $this->buildCombinedIdentifier(
+            $targetFolder,
+            $targetFileName
+        );
+        $this->assertFileName(
+            self::COMMAND_FILE_ADD,
+            $combinedFileIdentifier,
+            file_get_contents($sourceFilePath)
+        );
+    }
+
+    /**
+     * @param FileInterface $file
+     * @param string $targetFileName
+     */
+    public function onPreFileRename(FileInterface $file, string $targetFileName): void
+    {
+        $combinedFileIdentifier = $this->buildCombinedIdentifier(
+            $file->getParentFolder(),
+            $targetFileName
+        );
+
+        $this->assertFileName(
+            self::COMMAND_FILE_RENAME,
+            $combinedFileIdentifier
+        );
+    }
+
+    /**
+     * @param FileInterface $file
+     * @param string $localFilePath
+     */
+    public function onPreFileReplace(FileInterface $file, string $localFilePath): void
+    {
+        $combinedFileIdentifier = $this->buildCombinedIdentifier(
+            $file->getParentFolder(),
+            $file->getName()
+        );
+
+        $this->assertFileName(
+            self::COMMAND_FILE_REPLACE,
+            $combinedFileIdentifier
+        );
+    }
+
+    /**
+     * @param FileInterface $file
+     * @param FolderInterface $targetFolder
+     * @param string $targetFileName
+     */
+    public function onPreFileMove(FileInterface $file, FolderInterface $targetFolder, string $targetFileName): void
+    {
+        $combinedFileIdentifier = $this->buildCombinedIdentifier(
+            $targetFolder,
+            $targetFileName
+        );
+
+        $this->assertFileName(
+            self::COMMAND_FILE_MOVE,
+            $combinedFileIdentifier
+        );
+    }
+
+    /**
+     * @param FileInterface $file
+     * @param mixed $content
+     */
+    public function onPreFileSetContents(FileInterface $file, $content = null): void
+    {
+        $combinedFileIdentifier = $this->buildCombinedIdentifier(
+            $file->getParentFolder(),
+            $file->getName()
+        );
+
+        $this->assertFileName(
+            self::COMMAND_FILE_SET_CONTENTS,
+            $combinedFileIdentifier,
+            $content
+        );
+    }
+
+    /**
+     * @param string $command
+     * @param string $combinedFileIdentifier
+     * @param string $content
+     * @throws FormDefinitionPersistenceException
+     */
+    protected function assertFileName(
+        string $command,
+        string $combinedFileIdentifier,
+        string $content = null
+    ): void {
+        if (!StringUtility::endsWith($combinedFileIdentifier, FormPersistenceManager::FORM_DEFINITION_FILE_EXTENSION)) {
+            return;
+        }
+
+        $definedInvocation = $this->definedInvocations[$command] ?? null;
+        // whitelisted command
+        if ($definedInvocation === true) {
+            return;
+        }
+        // blacklisted command
+        if ($definedInvocation === false) {
+            throw new FormDefinitionPersistenceException(
+                sprintf(
+                    'Persisting form definition "%s" is denied',
+                    $combinedFileIdentifier
+                ),
+                1530281201
+            );
+        }
+
+        $contentSignature = null;
+        if ($content !== null) {
+            $contentSignature = $this->getContentSignature((string)$content);
+        }
+        $allowedInvocationIndex = $this->searchAllowedInvocation(
+            $command,
+            $combinedFileIdentifier,
+            $contentSignature
+        );
+
+        if ($allowedInvocationIndex === null) {
+            throw new FormDefinitionPersistenceException(
+                sprintf(
+                    'Persisting form definition "%s" is denied',
+                    $combinedFileIdentifier
+                ),
+                1530281202
+            );
+        }
+        unset($this->allowedInvocations[$allowedInvocationIndex]);
+    }
+
+    /**
+     * @param string $command
+     * @param string $combinedFileIdentifier
+     * @param string|null $contentSignature
+     * @return int|null
+     */
+    protected function searchAllowedInvocation(
+        string $command,
+        string $combinedFileIdentifier,
+        string $contentSignature = null
+    ): ?int {
+        foreach ($this->allowedInvocations as $index => $allowedInvocation) {
+            if (
+                $command === $allowedInvocation['command']
+                && $combinedFileIdentifier === $allowedInvocation['combinedFileIdentifier']
+                && $contentSignature === $allowedInvocation['contentSignature']
+            ) {
+                return $index;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * @param FolderInterface $folder
+     * @param string $fileName
+     * @return string
+     */
+    protected function buildCombinedIdentifier(FolderInterface $folder, string $fileName): string
+    {
+        return sprintf(
+            '%d:%s%s',
+            $folder->getStorage()->getUid(),
+            $folder->getIdentifier(),
+            $fileName
+        );
+    }
+}
diff --git a/typo3/sysext/form/Classes/Slot/FormDefinitionPersistenceException.php b/typo3/sysext/form/Classes/Slot/FormDefinitionPersistenceException.php
new file mode 100644 (file)
index 0000000..e828ecf
--- /dev/null
@@ -0,0 +1,23 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Form\Slot;
+
+/*
+ * 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!
+ */
+
+/**
+ * @internal
+ */
+class FormDefinitionPersistenceException extends \RuntimeException
+{
+}
index 057a911..4cbbfa0 100644 (file)
                                                                                                </span>
                                                                                        </f:else>
                                                                                        <f:else>
-                                                                                               <span title="id={form.identifier}" data-toggle="tooltip" data-placement="right">
-                                                                                                       <core:icon identifier="content-form" />
-                                                                                               </span>
+                                                                                               <f:if condition="{form.deprecatedFileExtension}">
+                                                                                                       <f:then>
+                                                                                                               <span title="{f:translate(key: 'LLL:EXT:form/Resources/Private/Language/Database.xlf:formManager.deprecated_file_extension')} {form.identifier}" data-toggle="tooltip" data-placement="top">
+                                                                                                                       <core:icon identifier="overlay-missing" />
+                                                                                                               </span>
+                                                                                                       </f:then>
+                                                                                                       <f:else>
+                                                                                                               <span title="id={form.identifier}" data-toggle="tooltip" data-placement="right">
+                                                                                                                       <core:icon identifier="content-form" />
+                                                                                                               </span>
+                                                                                                       </f:else>
+                                                                                               </f:if>
                                                                                        </f:else>
                                                                                </f:if>
                                                                        </td>
index be29c85..f1afef1 100644 (file)
             <trans-unit id="formManager.duplicate_identifier" xml:space="preserve">
                 <source>Duplicate identifier!</source>
             </trans-unit>
+            <trans-unit id="formManager.deprecated_file_extension" xml:space="preserve">
+                <source>Unsupported file extension!</source>
+            </trans-unit>
 
             <trans-unit id="formManager.selectablePrototypesConfiguration.standard.label" xml:space="preserve">
                 <source>Standard</source>
index 48106a2..1f8a974 100644 (file)
@@ -252,6 +252,7 @@ class DataStructureIdentifierHookTest extends UnitTestCase
                 [
                     'persistenceIdentifier' => 'hugo1',
                     'name' => 'myHugo1',
+                    'location' => 'extension',
                 ],
                 [
                     'myHugo1 (hugo1)',
@@ -264,6 +265,7 @@ class DataStructureIdentifierHookTest extends UnitTestCase
                     'persistenceIdentifier' => 'Error.yaml',
                     'label' => 'Test Error Label',
                     'name' => 'Test Error Name',
+                    'location' => 'extension',
                     'invalid' => true,
                 ],
                 [
diff --git a/typo3/sysext/form/Tests/Unit/Mvc/Persistence/Fixtures/BlankForm.form.yaml b/typo3/sysext/form/Tests/Unit/Mvc/Persistence/Fixtures/BlankForm.form.yaml
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/typo3/sysext/form/Tests/Unit/Mvc/Persistence/Fixtures/BlankForm.yaml b/typo3/sysext/form/Tests/Unit/Mvc/Persistence/Fixtures/BlankForm.yaml
deleted file mode 100644 (file)
index e69de29..0000000
index 252c698..0826601 100644 (file)
@@ -62,7 +62,7 @@ class FormPersistenceManagerTest extends UnitTestCase
             ],
         ]);
 
-        $input = 'EXT:form/Resources/Forms/_example.yaml';
+        $input = 'EXT:form/Resources/Forms/_example.form.yaml';
         $mockFormPersistenceManager->_call('load', $input);
     }
 
@@ -100,7 +100,7 @@ class FormPersistenceManagerTest extends UnitTestCase
             ],
         ]);
 
-        $input = 'EXT:form/Resources/Forms/_example.yaml';
+        $input = 'EXT:form/Resources/Forms/_example.form.yaml';
         $mockFormPersistenceManager->_call('save', $input, []);
     }
 
@@ -123,7 +123,7 @@ class FormPersistenceManagerTest extends UnitTestCase
             ],
         ]);
 
-        $input = 'EXT:form/Resources/Forms/_example.yaml';
+        $input = 'EXT:form/Resources/Forms/_example.form.yaml';
         $mockFormPersistenceManager->_call('save', $input, []);
     }
 
@@ -160,7 +160,7 @@ class FormPersistenceManagerTest extends UnitTestCase
             ->method('exists')
             ->willReturn(false);
 
-        $input = '-1:/user_uploads/_example.yaml';
+        $input = '-1:/user_uploads/_example.form.yaml';
         $mockFormPersistenceManager->_call('delete', $input);
     }
 
@@ -187,7 +187,7 @@ class FormPersistenceManagerTest extends UnitTestCase
             ],
         ]);
 
-        $input = 'EXT:form/Resources/Forms/_example.yaml';
+        $input = 'EXT:form/Resources/Forms/_example.form.yaml';
         $mockFormPersistenceManager->_call('delete', $input);
     }
 
@@ -215,7 +215,7 @@ class FormPersistenceManagerTest extends UnitTestCase
             ],
         ]);
 
-        $input = 'EXT:form/Resources/Forms/_example.yaml';
+        $input = 'EXT:form/Resources/Forms/_example.form.yaml';
         $mockFormPersistenceManager->_call('delete', $input);
     }
 
@@ -257,7 +257,7 @@ class FormPersistenceManagerTest extends UnitTestCase
             ->method('exists')
             ->willReturn(true);
 
-        $input = '-1:/user_uploads/_example.yaml';
+        $input = '-1:/user_uploads/_example.form.yaml';
         $mockFormPersistenceManager->_call('delete', $input);
     }
 
@@ -278,7 +278,7 @@ class FormPersistenceManagerTest extends UnitTestCase
             ],
         ]);
 
-        $input = 'EXT:form/Tests/Unit/Mvc/Persistence/Fixtures/BlankForm.yaml';
+        $input = 'EXT:form/Tests/Unit/Mvc/Persistence/Fixtures/BlankForm.form.yaml';
         $this->assertTrue($mockFormPersistenceManager->_call('exists', $input));
     }
 
@@ -349,7 +349,7 @@ class FormPersistenceManagerTest extends UnitTestCase
             ->method('getStorageByUid')
             ->willReturn($mockStorage);
 
-        $input = '-1:/user_uploads/_example.yaml';
+        $input = '-1:/user_uploads/_example.form.yaml';
         $this->assertTrue($mockFormPersistenceManager->_call('exists', $input));
     }
 
@@ -430,7 +430,7 @@ class FormPersistenceManagerTest extends UnitTestCase
             ->willReturn(false);
 
         $input = 'example';
-        $expected = '-1:/user_uploads/example_2.yaml';
+        $expected = '-1:/user_uploads/example_2.form.yaml';
         $this->assertSame($expected, $mockFormPersistenceManager->_call('getUniquePersistenceIdentifier', $input, '-1:/user_uploads/'));
     }
 
@@ -456,7 +456,7 @@ class FormPersistenceManagerTest extends UnitTestCase
             ->willReturn(false);
 
         $input = 'example';
-        $expected = '#^-1:/user_uploads/example_([0-9]{10}).yaml$#';
+        $expected = '#^-1:/user_uploads/example_([0-9]{10}).form.yaml$#';
 
         $returnValue = $mockFormPersistenceManager->_call('getUniquePersistenceIdentifier', $input, '-1:/user_uploads/');
         $this->assertEquals(1, preg_match($expected, $returnValue));
index f0224f5..888e262 100644 (file)
@@ -2,6 +2,21 @@
 defined('TYPO3_MODE') or die();
 
 call_user_func(function () {
+    // Register upgrade wizard in install tool
+    $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/install']['update']['formFileExtension']
+        = \TYPO3\CMS\Form\Hooks\FormFileExtensionUpdate::class;
+
+    // Context menu item handling for form files
+    $GLOBALS['TYPO3_CONF_VARS']['BE']['ContextMenu']['ItemProviders'][1530637161]
+        = \TYPO3\CMS\Form\Hooks\FormFileProvider::class;
+
+    $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/impexp/class.tx_impexp.php']['before_addSysFileRecord'][1530637161]
+        = \TYPO3\CMS\Form\Hooks\ImportExportHook::class . '->beforeAddSysFileRecordOnImport';
+
+    // File list edit icons
+    $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['fileList']['editIconsHook'][1530637161]
+        = \TYPO3\CMS\Form\Hooks\FileListEditIconsHook::class;
+
     // Hook to enrich tt_content form flex element with finisher settings and form list drop down
     $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS'][\TYPO3\CMS\Core\Configuration\FlexForm\FlexFormTools::class]['flexParsing'][
         \TYPO3\CMS\Form\Hooks\DataStructureIdentifierHook::class
@@ -69,4 +84,45 @@ call_user_func(function () {
         ['FormFrontend' => 'perform'],
         \TYPO3\CMS\Extbase\Utility\ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
     );
+
+    // Register slots for file handling
+    $signalSlotDispatcher = \TYPO3\CMS\Core\Utility\GeneralUtility::makeInstance(
+        \TYPO3\CMS\Extbase\SignalSlot\Dispatcher::class
+    );
+    $signalSlotDispatcher->connect(
+        \TYPO3\CMS\Core\Resource\ResourceStorage::class,
+        \TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileCreate,
+        \TYPO3\CMS\Form\Slot\FilePersistenceSlot::class,
+        'onPreFileCreate'
+    );
+    $signalSlotDispatcher->connect(
+        \TYPO3\CMS\Core\Resource\ResourceStorage::class,
+        \TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileAdd,
+        \TYPO3\CMS\Form\Slot\FilePersistenceSlot::class,
+        'onPreFileAdd'
+    );
+    $signalSlotDispatcher->connect(
+        \TYPO3\CMS\Core\Resource\ResourceStorage::class,
+        \TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileRename,
+        \TYPO3\CMS\Form\Slot\FilePersistenceSlot::class,
+        'onPreFileRename'
+    );
+    $signalSlotDispatcher->connect(
+        \TYPO3\CMS\Core\Resource\ResourceStorage::class,
+        \TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileReplace,
+        \TYPO3\CMS\Form\Slot\FilePersistenceSlot::class,
+        'onPreFileReplace'
+    );
+    $signalSlotDispatcher->connect(
+        \TYPO3\CMS\Core\Resource\ResourceStorage::class,
+        \TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileMove,
+        \TYPO3\CMS\Form\Slot\FilePersistenceSlot::class,
+        'onPreFileMove'
+    );
+    $signalSlotDispatcher->connect(
+        \TYPO3\CMS\Core\Resource\ResourceStorage::class,
+        \TYPO3\CMS\Core\Resource\ResourceStorageInterface::SIGNAL_PreFileSetContents,
+        \TYPO3\CMS\Form\Slot\FilePersistenceSlot::class,
+        'onPreFileSetContents'
+    );
 });
index 5284f14..100c251 100644 (file)
@@ -430,6 +430,12 @@ class Import extends ImportExport
                     $importFolder = $storage->getFolder($folderName);
                 }
 
+                $this->callHook('before_addSysFileRecord', [
+                    'fileRecord' => $fileRecord,
+                    'importFolder' => $importFolder,
+                    'temporaryFile' => $temporaryFile
+                ]);
+
                 try {
                     /** @var $newFile File */
                     $newFile = $storage->addFile($temporaryFile, $importFolder, $fileRecord['name']);