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