1<?php
2declare(strict_types = 1);
3namespace TYPO3\CMS\Form\Controller;
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
18use Symfony\Component\Yaml\Yaml;
19use TYPO3\CMS\Backend\Template\Components\ButtonBar;
20use TYPO3\CMS\Backend\Utility\BackendUtility;
21use TYPO3\CMS\Backend\View\BackendTemplateView;
22use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
23use TYPO3\CMS\Core\Charset\CharsetConverter;
24use TYPO3\CMS\Core\Imaging\Icon;
25use TYPO3\CMS\Core\Imaging\IconFactory;
26use TYPO3\CMS\Core\Localization\LanguageService;
27use TYPO3\CMS\Core\Messaging\AbstractMessage;
28use TYPO3\CMS\Core\Page\PageRenderer;
29use TYPO3\CMS\Core\Utility\ArrayUtility;
30use TYPO3\CMS\Core\Utility\GeneralUtility;
31use TYPO3\CMS\Extbase\Mvc\View\JsonView;
32use TYPO3\CMS\Form\Exception as FormException;
33use TYPO3\CMS\Form\Mvc\Persistence\Exception\PersistenceManagerException;
34use TYPO3\CMS\Form\Service\DatabaseService;
35use TYPO3\CMS\Form\Service\TranslationService;
36
37/**
38 * The form manager controller
39 *
40 * Scope: backend
41 * @internal
42 */
43class FormManagerController extends AbstractBackendController
44{
45
46    /**
47     * @var DatabaseService
48     */
49    protected $databaseService;
50
51    /**
52     * @param \TYPO3\CMS\Form\Service\DatabaseService $databaseService
53     * @internal
54     */
55    public function injectDatabaseService(\TYPO3\CMS\Form\Service\DatabaseService $databaseService)
56    {
57        $this->databaseService = $databaseService;
58    }
59
60    /**
61     * Default View Container
62     *
63     * @var BackendTemplateView
64     */
65    protected $defaultViewObjectName = BackendTemplateView::class;
66
67    /**
68     * Displays the Form Manager
69     *
70     * @internal
71     */
72    public function indexAction()
73    {
74        $this->registerDocheaderButtons();
75        $this->view->getModuleTemplate()->setModuleName($this->request->getPluginName() . '_' . $this->request->getControllerName());
76        $this->view->getModuleTemplate()->setFlashMessageQueue($this->controllerContext->getFlashMessageQueue());
77
78        $this->view->assign('forms', $this->getAvailableFormDefinitions());
79        $this->view->assign('stylesheets', $this->resolveResourcePaths($this->formSettings['formManager']['stylesheets']));
80        $this->view->assign('dynamicRequireJsModules', $this->formSettings['formManager']['dynamicRequireJsModules']);
81        $this->view->assign('formManagerAppInitialData', $this->getFormManagerAppInitialData());
82        if (!empty($this->formSettings['formManager']['javaScriptTranslationFile'])) {
83            $this->getPageRenderer()->addInlineLanguageLabelFile($this->formSettings['formManager']['javaScriptTranslationFile']);
84        }
85    }
86
87    /**
88     * Initialize the create action.
89     * This action uses the Fluid JsonView::class as view.
90     *
91     * @internal
92     */
93    public function initializeCreateAction()
94    {
95        $this->defaultViewObjectName = JsonView::class;
96    }
97
98    /**
99     * Creates a new Form and redirects to the Form Editor
100     *
101     * @param string $formName
102     * @param string $templatePath
103     * @param string $prototypeName
104     * @param string $savePath
105     * @throws FormException
106     * @throws PersistenceManagerException
107     * @internal
108     */
109    public function createAction(string $formName, string $templatePath, string $prototypeName, string $savePath)
110    {
111        if (!$this->formPersistenceManager->isAllowedPersistencePath($savePath)) {
112            throw new PersistenceManagerException(sprintf('Save to path "%s" is not allowed', $savePath), 1614500657);
113        }
114
115        if (!$this->isValidTemplatePath($prototypeName, $templatePath)) {
116            throw new FormException(sprintf('The template path "%s" is not allowed', $templatePath), 1329233410);
117        }
118        if (empty($formName)) {
119            throw new FormException('No form name', 1472312204);
120        }
121
122        $templatePath = GeneralUtility::getFileAbsFileName($templatePath);
123        $form = Yaml::parse(file_get_contents($templatePath));
124        $form['label'] = $formName;
125        $form['identifier'] = $this->formPersistenceManager->getUniqueIdentifier($this->convertFormNameToIdentifier($formName));
126        $form['prototypeName'] = $prototypeName;
127
128        $formPersistenceIdentifier = $this->formPersistenceManager->getUniquePersistenceIdentifier($form['identifier'], $savePath);
129
130        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['beforeFormCreate'] ?? [] as $className) {
131            $hookObj = GeneralUtility::makeInstance($className);
132            if (method_exists($hookObj, 'beforeFormCreate')) {
133                $form = $hookObj->beforeFormCreate(
134                    $formPersistenceIdentifier,
135                    $form
136                );
137            }
138        }
139
140        $response = [
141            'status' => 'success',
142            'url' => $this->controllerContext->getUriBuilder()->uriFor('index', ['formPersistenceIdentifier' => $formPersistenceIdentifier], 'FormEditor')
143        ];
144
145        try {
146            $this->formPersistenceManager->save($formPersistenceIdentifier, $form);
147        } catch (PersistenceManagerException $e) {
148            $response = [
149                'status' => 'error',
150                'message' => $e->getMessage(),
151                'code' => $e->getCode(),
152            ];
153        }
154
155        $this->view->assign('response', $response);
156        // createAction uses the Extbase JsonView::class.
157        // That's why we have to set the view variables in this way.
158        $this->view->setVariablesToRender([
159            'response',
160        ]);
161    }
162
163    /**
164     * Initialize the duplicate action.
165     * This action uses the Fluid JsonView::class as view.
166     *
167     * @internal
168     */
169    public function initializeDuplicateAction()
170    {
171        $this->defaultViewObjectName = JsonView::class;
172    }
173
174    /**
175     * Duplicates a given formDefinition and redirects to the Form Editor
176     *
177     * @param string $formName
178     * @param string $formPersistenceIdentifier persistence identifier of the form to duplicate
179     * @param string $savePath
180     * @throws PersistenceManagerException
181     * @internal
182     */
183    public function duplicateAction(string $formName, string $formPersistenceIdentifier, string $savePath)
184    {
185        if (!$this->formPersistenceManager->isAllowedPersistencePath($savePath)) {
186            throw new PersistenceManagerException(sprintf('Save to path "%s" is not allowed', $savePath), 1614500658);
187        }
188        if (!$this->formPersistenceManager->isAllowedPersistencePath($formPersistenceIdentifier)) {
189            throw new PersistenceManagerException(sprintf('Read of "%s" is not allowed', $formPersistenceIdentifier), 1614500659);
190        }
191
192        $formToDuplicate = $this->formPersistenceManager->load($formPersistenceIdentifier);
193        $formToDuplicate['label'] = $formName;
194        $formToDuplicate['identifier'] = $this->formPersistenceManager->getUniqueIdentifier($this->convertFormNameToIdentifier($formName));
195
196        $formPersistenceIdentifier = $this->formPersistenceManager->getUniquePersistenceIdentifier($formToDuplicate['identifier'], $savePath);
197
198        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['beforeFormDuplicate'] ?? [] as $className) {
199            $hookObj = GeneralUtility::makeInstance($className);
200            if (method_exists($hookObj, 'beforeFormDuplicate')) {
201                $formToDuplicate = $hookObj->beforeFormDuplicate(
202                    $formPersistenceIdentifier,
203                    $formToDuplicate
204                );
205            }
206        }
207
208        $response = [
209            'status' => 'success',
210            'url' => $this->controllerContext->getUriBuilder()->uriFor('index', ['formPersistenceIdentifier' => $formPersistenceIdentifier], 'FormEditor')
211        ];
212
213        try {
214            $this->formPersistenceManager->save($formPersistenceIdentifier, $formToDuplicate);
215        } catch (PersistenceManagerException $e) {
216            $response = [
217                'status' => 'error',
218                'message' => $e->getMessage(),
219                'code' => $e->getCode(),
220            ];
221        }
222
223        $this->view->assign('response', $response);
224        // createAction uses the Extbase JsonView::class.
225        // That's why we have to set the view variables in this way.
226        $this->view->setVariablesToRender([
227            'response',
228        ]);
229    }
230
231    /**
232     * Initialize the references action.
233     * This action uses the Fluid JsonView::class as view.
234     *
235     * @internal
236     */
237    public function initializeReferencesAction()
238    {
239        $this->defaultViewObjectName = JsonView::class;
240    }
241
242    /**
243     * Show references to this persistence identifier
244     *
245     * @param string $formPersistenceIdentifier persistence identifier of the form to duplicate
246     * @throws PersistenceManagerException
247     * @internal
248     */
249    public function referencesAction(string $formPersistenceIdentifier)
250    {
251        if (!$this->formPersistenceManager->isAllowedPersistencePath($formPersistenceIdentifier)) {
252            throw new PersistenceManagerException(sprintf('Read from "%s" is not allowed', $formPersistenceIdentifier), 1614500660);
253        }
254
255        $this->view->assign('references', $this->getProcessedReferencesRows($formPersistenceIdentifier));
256        $this->view->assign('formPersistenceIdentifier', $formPersistenceIdentifier);
257        // referencesAction uses the extbase JsonView::class.
258        // That's why we have to set the view variables in this way.
259        $this->view->setVariablesToRender([
260            'references',
261            'formPersistenceIdentifier'
262        ]);
263    }
264
265    /**
266     * Delete a formDefinition identified by the $formPersistenceIdentifier.
267     *
268     * @param string $formPersistenceIdentifier persistence identifier to delete
269     * @throws PersistenceManagerException
270     * @internal
271     */
272    public function deleteAction(string $formPersistenceIdentifier)
273    {
274        if (!$this->formPersistenceManager->isAllowedPersistencePath($formPersistenceIdentifier)) {
275            throw new PersistenceManagerException(sprintf('Delete "%s" is not allowed', $formPersistenceIdentifier), 1614500661);
276        }
277
278        if (empty($this->databaseService->getReferencesByPersistenceIdentifier($formPersistenceIdentifier))) {
279            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['beforeFormDelete'] ?? [] as $className) {
280                $hookObj = GeneralUtility::makeInstance($className);
281                if (method_exists($hookObj, 'beforeFormDelete')) {
282                    $hookObj->beforeFormDelete(
283                        $formPersistenceIdentifier
284                    );
285                }
286            }
287
288            $this->formPersistenceManager->delete($formPersistenceIdentifier);
289        } else {
290            $controllerConfiguration = TranslationService::getInstance()->translateValuesRecursive(
291                $this->formSettings['formManager']['controller'],
292                $this->formSettings['formManager']['translationFile']
293            );
294
295            $this->addFlashMessage(
296                sprintf($controllerConfiguration['deleteAction']['errorMessage'], $formPersistenceIdentifier),
297                $controllerConfiguration['deleteAction']['errorTitle'],
298                AbstractMessage::ERROR,
299                true
300            );
301        }
302        $this->redirect('index');
303    }
304
305    /**
306     * Return a list of all accessible file mountpoints.
307     *
308     * Only registered mountpoints from
309     * TYPO3.CMS.Form.persistenceManager.allowedFileMounts
310     * are listet. This is list will be reduced by the configured
311     * mountpoints for the current backend user.
312     *
313     * @return array
314     */
315    protected function getAccessibleFormStorageFolders(): array
316    {
317        $preparedAccessibleFormStorageFolders = [];
318        foreach ($this->formPersistenceManager->getAccessibleFormStorageFolders() as $identifier => $folder) {
319            // TODO: deprecated since TYPO3 v9, will be removed in TYPO3 v10.0
320            if ($folder->getCombinedIdentifier() === '1:/user_upload/') {
321                continue;
322            }
323
324            $preparedAccessibleFormStorageFolders[] = [
325                'label' => $folder->getName(),
326                'value' => $identifier
327            ];
328        }
329
330        if ($this->formSettings['persistenceManager']['allowSaveToExtensionPaths']) {
331            foreach ($this->formPersistenceManager->getAccessibleExtensionFolders() as $relativePath => $fullPath) {
332                $preparedAccessibleFormStorageFolders[] = [
333                    'label' => $relativePath,
334                    'value' => $relativePath
335                ];
336            }
337        }
338
339        return $preparedAccessibleFormStorageFolders;
340    }
341
342    /**
343     * Returns the json encoded data which is used by the form editor
344     * JavaScript app.
345     *
346     * @return string
347     */
348    protected function getFormManagerAppInitialData(): string
349    {
350        $formManagerAppInitialData = [
351            'selectablePrototypesConfiguration' => $this->formSettings['formManager']['selectablePrototypesConfiguration'],
352            'accessibleFormStorageFolders' => $this->getAccessibleFormStorageFolders(),
353            'endpoints' => [
354                'create' => $this->controllerContext->getUriBuilder()->uriFor('create'),
355                'duplicate' => $this->controllerContext->getUriBuilder()->uriFor('duplicate'),
356                'delete' => $this->controllerContext->getUriBuilder()->uriFor('delete'),
357                'references' => $this->controllerContext->getUriBuilder()->uriFor('references')
358            ],
359        ];
360
361        $formManagerAppInitialData = ArrayUtility::reIndexNumericArrayKeysRecursive($formManagerAppInitialData);
362        $formManagerAppInitialData = TranslationService::getInstance()->translateValuesRecursive(
363            $formManagerAppInitialData,
364            $this->formSettings['formManager']['translationFile'] ?? null
365        );
366        return json_encode($formManagerAppInitialData);
367    }
368
369    /**
370     * List all formDefinitions which can be loaded through t form persistence
371     * manager. Enrich this data by a reference counter.
372     * @return array
373     */
374    protected function getAvailableFormDefinitions(): array
375    {
376        $allReferencesForFileUid = $this->databaseService->getAllReferencesForFileUid();
377        $allReferencesForPersistenceIdentifier = $this->databaseService->getAllReferencesForPersistenceIdentifier();
378
379        $availableFormDefinitions = [];
380        foreach ($this->formPersistenceManager->listForms() as $formDefinition) {
381            $referenceCount  = 0;
382            if (
383                isset($formDefinition['fileUid'])
384                && array_key_exists($formDefinition['fileUid'], $allReferencesForFileUid)
385            ) {
386                $referenceCount = $allReferencesForFileUid[$formDefinition['fileUid']];
387            } elseif (array_key_exists($formDefinition['persistenceIdentifier'], $allReferencesForPersistenceIdentifier)) {
388                $referenceCount = $allReferencesForPersistenceIdentifier[$formDefinition['persistenceIdentifier']];
389            }
390
391            $formDefinition['referenceCount'] = $referenceCount;
392            $availableFormDefinitions[] = $formDefinition;
393        }
394
395        return $availableFormDefinitions;
396    }
397
398    /**
399     * Returns an array with informations about the references for a
400     * formDefinition identified by $persistenceIdentifier.
401     *
402     * @param string $persistenceIdentifier
403     * @return array
404     * @throws \InvalidArgumentException
405     */
406    protected function getProcessedReferencesRows(string $persistenceIdentifier): array
407    {
408        if (empty($persistenceIdentifier)) {
409            throw new \InvalidArgumentException('$persistenceIdentifier must not be empty.', 1477071939);
410        }
411
412        $references = [];
413        $iconFactory = GeneralUtility::makeInstance(IconFactory::class);
414
415        $referenceRows = $this->databaseService->getReferencesByPersistenceIdentifier($persistenceIdentifier);
416        foreach ($referenceRows as &$referenceRow) {
417            $record = $this->getRecord($referenceRow['tablename'], $referenceRow['recuid']);
418            if (!$record) {
419                continue;
420            }
421            $pageRecord = $this->getRecord('pages', $record['pid']);
422            $urlParameters = [
423                'edit' => [
424                    $referenceRow['tablename'] => [
425                        $referenceRow['recuid'] => 'edit'
426                    ]
427                ],
428                'returnUrl' => $this->getModuleUrl('web_FormFormbuilder')
429            ];
430
431            $references[] = [
432                'recordPageTitle' => is_array($pageRecord) ? $this->getRecordTitle('pages', $pageRecord) : '',
433                'recordTitle' => $this->getRecordTitle($referenceRow['tablename'], $record, true),
434                'recordIcon' => $iconFactory->getIconForRecord($referenceRow['tablename'], $record, Icon::SIZE_SMALL)->render(),
435                'recordUid' => $referenceRow['recuid'],
436                'recordEditUrl' => $this->getModuleUrl('record_edit', $urlParameters),
437            ];
438        }
439        return $references;
440    }
441
442    /**
443     * Check if a given $templatePath for a given $prototypeName is valid
444     * and accessible.
445     *
446     * Valid template paths has to be configured within
447     * TYPO3.CMS.Form.formManager.selectablePrototypesConfiguration.[('identifier':  $prototypeName)].newFormTemplates.[('templatePath': $templatePath)]
448     *
449     * @param string $prototypeName
450     * @param string $templatePath
451     * @return bool
452     */
453    protected function isValidTemplatePath(string $prototypeName, string $templatePath): bool
454    {
455        $isValid = false;
456        foreach ($this->formSettings['formManager']['selectablePrototypesConfiguration'] as $prototypesConfiguration) {
457            if ($prototypesConfiguration['identifier'] !== $prototypeName) {
458                continue;
459            }
460            foreach ($prototypesConfiguration['newFormTemplates'] as $templatesConfiguration) {
461                if ($templatesConfiguration['templatePath'] !== $templatePath) {
462                    continue;
463                }
464                $isValid = true;
465                break;
466            }
467        }
468
469        $templatePath = GeneralUtility::getFileAbsFileName($templatePath);
470        if (!is_file($templatePath)) {
471            $isValid = false;
472        }
473
474        return $isValid;
475    }
476
477    /**
478     * Register document header buttons
479     *
480     * @throws \InvalidArgumentException
481     */
482    protected function registerDocheaderButtons()
483    {
484        /** @var ButtonBar $buttonBar */
485        $buttonBar = $this->view->getModuleTemplate()->getDocHeaderComponent()->getButtonBar();
486        $currentRequest = $this->request;
487        $moduleName = $currentRequest->getPluginName();
488        $getVars = $this->request->getArguments();
489
490        // Create new
491        $addFormButton = $buttonBar->makeLinkButton()
492            ->setDataAttributes(['identifier' => 'newForm'])
493            ->setHref('#')
494            ->setTitle($this->getLanguageService()->sL('LLL:EXT:form/Resources/Private/Language/Database.xlf:formManager.create_new_form'))
495            ->setIcon($this->view->getModuleTemplate()->getIconFactory()->getIcon('actions-add', Icon::SIZE_SMALL));
496        $buttonBar->addButton($addFormButton, ButtonBar::BUTTON_POSITION_LEFT);
497
498        // Reload
499        $reloadButton = $buttonBar->makeLinkButton()
500            ->setHref(GeneralUtility::getIndpEnv('REQUEST_URI'))
501            ->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.reload'))
502            ->setIcon($this->view->getModuleTemplate()->getIconFactory()->getIcon('actions-refresh', Icon::SIZE_SMALL));
503        $buttonBar->addButton($reloadButton, ButtonBar::BUTTON_POSITION_RIGHT);
504
505        // Shortcut
506        $mayMakeShortcut = $this->getBackendUser()->mayMakeShortcut();
507        if ($mayMakeShortcut) {
508            $extensionName = $currentRequest->getControllerExtensionName();
509            if (count($getVars) === 0) {
510                $modulePrefix = strtolower('tx_' . $extensionName . '_' . $moduleName);
511                $getVars = ['id', 'route', $modulePrefix];
512            }
513
514            $shortcutButton = $buttonBar->makeShortcutButton()
515                ->setModuleName($moduleName)
516                ->setDisplayName($this->getLanguageService()->sL('LLL:EXT:form/Resources/Private/Language/Database.xlf:module.shortcut_name'))
517                ->setGetVariables($getVars);
518            $buttonBar->addButton($shortcutButton, ButtonBar::BUTTON_POSITION_RIGHT);
519        }
520    }
521
522    /**
523     * Returns a form identifier which is the lower cased form name.
524     *
525     * @param string $formName
526     * @return string
527     */
528    protected function convertFormNameToIdentifier(string $formName): string
529    {
530        $csConverter = GeneralUtility::makeInstance(CharsetConverter::class);
531
532        $formIdentifier = $csConverter->specCharsToASCII('utf-8', $formName);
533        $formIdentifier = preg_replace('/[^a-zA-Z0-9-_]/', '', $formIdentifier);
534        $formIdentifier = lcfirst($formIdentifier);
535        return $formIdentifier;
536    }
537
538    /**
539     * Wrapper used for unit testing.
540     *
541     * @param string $table
542     * @param int $uid
543     * @return array|null
544     */
545    protected function getRecord(string $table, int $uid)
546    {
547        return BackendUtility::getRecord($table, $uid);
548    }
549
550    /**
551     * Wrapper used for unit testing.
552     *
553     * @param string $table
554     * @param array $row
555     * @param bool $prep
556     * @return string
557     */
558    protected function getRecordTitle(string $table, array $row, bool $prep = false): string
559    {
560        return BackendUtility::getRecordTitle($table, $row, $prep);
561    }
562
563    /**
564     * Wrapper used for unit testing.
565     *
566     * @param string $moduleName
567     * @param array $urlParameters
568     * @return string
569     */
570    protected function getModuleUrl(string $moduleName, array $urlParameters = []): string
571    {
572        /** @var \TYPO3\CMS\Backend\Routing\UriBuilder $uriBuilder */
573        $uriBuilder = GeneralUtility::makeInstance(\TYPO3\CMS\Backend\Routing\UriBuilder::class);
574        return (string)$uriBuilder->buildUriFromRoute($moduleName, $urlParameters);
575    }
576
577    /**
578     * Returns the current BE user.
579     *
580     * @return BackendUserAuthentication
581     */
582    protected function getBackendUser(): BackendUserAuthentication
583    {
584        return $GLOBALS['BE_USER'];
585    }
586
587    /**
588     * Returns the Language Service
589     *
590     * @return LanguageService
591     */
592    protected function getLanguageService(): LanguageService
593    {
594        return $GLOBALS['LANG'];
595    }
596
597    /**
598     * Returns the page renderer
599     *
600     * @return PageRenderer
601     */
602    protected function getPageRenderer(): PageRenderer
603    {
604        return GeneralUtility::makeInstance(PageRenderer::class);
605    }
606}
607