[TASK] Show speaking exception message if form definition is invalid
[Packages/TYPO3.CMS.git] / typo3 / sysext / form / Classes / Mvc / Persistence / FormPersistenceManager.php
1 <?php
2 declare(strict_types = 1);
3 namespace TYPO3\CMS\Form\Mvc\Persistence;
4
5 /*
6 * This file is part of the TYPO3 CMS project.
7 *
8 * It originated from the Neos.Form package (www.neos.io)
9 *
10 * It is free software; you can redistribute it and/or modify it under
11 * the terms of the GNU General Public License, either version 2
12 * of the License, or any later version.
13 *
14 * For the full copyright and license information, please read the
15 * LICENSE.txt file that was distributed with this source code.
16 *
17 * The TYPO3 project - inspiring people to share!
18 */
19
20 use TYPO3\CMS\Core\Resource\Exception\FolderDoesNotExistException;
21 use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException;
22 use TYPO3\CMS\Core\Resource\File;
23 use TYPO3\CMS\Core\Resource\Filter\FileExtensionFilter;
24 use TYPO3\CMS\Core\Resource\Folder;
25 use TYPO3\CMS\Core\Resource\ResourceStorage;
26 use TYPO3\CMS\Core\Utility\GeneralUtility;
27 use TYPO3\CMS\Core\Utility\PathUtility;
28 use TYPO3\CMS\Extbase\Object\ObjectManager;
29 use TYPO3\CMS\Form\Mvc\Configuration\ConfigurationManagerInterface;
30 use TYPO3\CMS\Form\Mvc\Configuration\Exception\FileWriteException;
31 use TYPO3\CMS\Form\Mvc\Persistence\Exception\NoUniqueIdentifierException;
32 use TYPO3\CMS\Form\Mvc\Persistence\Exception\NoUniquePersistenceIdentifierException;
33 use TYPO3\CMS\Form\Mvc\Persistence\Exception\PersistenceManagerException;
34
35 /**
36 * Concrete implementation of the FormPersistenceManagerInterface
37 *
38 * Scope: frontend / backend
39 */
40 class FormPersistenceManager implements FormPersistenceManagerInterface
41 {
42
43 /**
44 * @var \TYPO3\CMS\Form\Mvc\Configuration\YamlSource
45 */
46 protected $yamlSource;
47
48 /**
49 * @var \TYPO3\CMS\Core\Resource\StorageRepository
50 */
51 protected $storageRepository;
52
53 /**
54 * @var array
55 */
56 protected $formSettings;
57
58 /**
59 * @param \TYPO3\CMS\Form\Mvc\Configuration\YamlSource $yamlSource
60 * @internal
61 */
62 public function injectYamlSource(\TYPO3\CMS\Form\Mvc\Configuration\YamlSource $yamlSource)
63 {
64 $this->yamlSource = $yamlSource;
65 }
66
67 /**
68 * @param \TYPO3\CMS\Core\Resource\StorageRepository $storageRepository
69 * @internal
70 */
71 public function injectStorageRepository(\TYPO3\CMS\Core\Resource\StorageRepository $storageRepository)
72 {
73 $this->storageRepository = $storageRepository;
74 }
75
76 /**
77 * @internal
78 */
79 public function initializeObject()
80 {
81 $this->formSettings = GeneralUtility::makeInstance(ObjectManager::class)
82 ->get(ConfigurationManagerInterface::class)
83 ->getConfiguration(ConfigurationManagerInterface::CONFIGURATION_TYPE_YAML_SETTINGS, 'form');
84 }
85
86 /**
87 * Load the array formDefinition identified by $persistenceIdentifier, and return it.
88 * Only files with the extension .yaml are loaded.
89 *
90 * @param string $persistenceIdentifier
91 * @return array
92 * @throws PersistenceManagerException
93 * @internal
94 */
95 public function load(string $persistenceIdentifier): array
96 {
97 if (pathinfo($persistenceIdentifier, PATHINFO_EXTENSION) !== 'yaml') {
98 throw new PersistenceManagerException(sprintf('The file "%s" could not be loaded.', $persistenceIdentifier), 1477679819);
99 }
100
101 if (strpos($persistenceIdentifier, 'EXT:') === 0) {
102 if (!array_key_exists(pathinfo($persistenceIdentifier, PATHINFO_DIRNAME) . '/', $this->getAccessibleExtensionFolders())) {
103 throw new PersistenceManagerException(sprintf('The file "%s" could not be loaded.', $persistenceIdentifier), 1484071985);
104 }
105 $file = $persistenceIdentifier;
106 } else {
107 $file = $this->getFileByIdentifier($persistenceIdentifier);
108 }
109
110 try {
111 $yaml = $this->yamlSource->load([$file]);
112 } catch (\Exception $e) {
113 $yaml = [
114 'type' => 'Form',
115 'identifier' => $file->getCombinedIdentifier(),
116 'label' => $e->getMessage(),
117 'error' => [
118 'code' => $e->getCode(),
119 'message' => $e->getMessage()
120 ],
121 'invalid' => true,
122 ];
123 }
124
125 return $yaml;
126 }
127
128 /**
129 * Save the array form representation identified by $persistenceIdentifier.
130 * Only files with the extension .yaml are saved.
131 * If the formDefinition is located within a EXT: resource, save is only
132 * allowed if the configuration path
133 * TYPO3.CMS.Form.persistenceManager.allowSaveToExtensionPaths
134 * is set to true.
135 *
136 * @param string $persistenceIdentifier
137 * @param array $formDefinition
138 * @throws PersistenceManagerException
139 * @internal
140 */
141 public function save(string $persistenceIdentifier, array $formDefinition)
142 {
143 if (pathinfo($persistenceIdentifier, PATHINFO_EXTENSION) !== 'yaml') {
144 throw new PersistenceManagerException(sprintf('The file "%s" could not be saved.', $persistenceIdentifier), 1477679820);
145 }
146
147 if (strpos($persistenceIdentifier, 'EXT:') === 0) {
148 if (!$this->formSettings['persistenceManager']['allowSaveToExtensionPaths']) {
149 throw new PersistenceManagerException('Save to extension paths is not allowed.', 1477680881);
150 }
151 if (!array_key_exists(pathinfo($persistenceIdentifier, PATHINFO_DIRNAME) . '/', $this->getAccessibleExtensionFolders())) {
152 throw new PersistenceManagerException(sprintf('The file "%s" could not be saved.', $persistenceIdentifier), 1484073571);
153 }
154 $fileToSave = GeneralUtility::getFileAbsFileName($persistenceIdentifier);
155 } else {
156 $fileToSave = $this->getOrCreateFile($persistenceIdentifier);
157 }
158
159 try {
160 $this->yamlSource->save($fileToSave, $formDefinition);
161 } catch (FileWriteException $e) {
162 throw new PersistenceManagerException(sprintf(
163 'The file "%s" could not be saved: %s',
164 $persistenceIdentifier,
165 $e->getMessage()
166 ), 1512582637, $e);
167 }
168 }
169
170 /**
171 * Delete the form representation identified by $persistenceIdentifier.
172 * Only files with the extension .yaml are removed.
173 *
174 * @param string $persistenceIdentifier
175 * @throws PersistenceManagerException
176 * @internal
177 */
178 public function delete(string $persistenceIdentifier)
179 {
180 if (pathinfo($persistenceIdentifier, PATHINFO_EXTENSION) !== 'yaml') {
181 throw new PersistenceManagerException(sprintf('The file "%s" could not be removed.', $persistenceIdentifier), 1472239534);
182 }
183 if (!$this->exists($persistenceIdentifier)) {
184 throw new PersistenceManagerException(sprintf('The file "%s" could not be removed.', $persistenceIdentifier), 1472239535);
185 }
186 if (strpos($persistenceIdentifier, 'EXT:') === 0) {
187 if (!$this->formSettings['persistenceManager']['allowDeleteFromExtensionPaths']) {
188 throw new PersistenceManagerException(sprintf('The file "%s" could not be removed.', $persistenceIdentifier), 1472239536);
189 }
190 if (!array_key_exists(pathinfo($persistenceIdentifier, PATHINFO_DIRNAME) . '/', $this->getAccessibleExtensionFolders())) {
191 throw new PersistenceManagerException(sprintf('The file "%s" could not be removed.', $persistenceIdentifier), 1484073878);
192 }
193 $fileToDelete = GeneralUtility::getFileAbsFileName($persistenceIdentifier);
194 unlink($fileToDelete);
195 } else {
196 list($storageUid, $fileIdentifier) = explode(':', $persistenceIdentifier, 2);
197 $storage = $this->getStorageByUid((int)$storageUid);
198 $file = $storage->getFile($fileIdentifier);
199 if (!$storage->checkFileActionPermission('delete', $file)) {
200 throw new PersistenceManagerException(sprintf('No delete access to file "%s".', $persistenceIdentifier), 1472239516);
201 }
202 $storage->deleteFile($file);
203 }
204 }
205
206 /**
207 * Check whether a form with the specified $persistenceIdentifier exists
208 *
209 * @param string $persistenceIdentifier
210 * @return bool TRUE if a form with the given $persistenceIdentifier can be loaded, otherwise FALSE
211 * @internal
212 */
213 public function exists(string $persistenceIdentifier): bool
214 {
215 $exists = false;
216 if (pathinfo($persistenceIdentifier, PATHINFO_EXTENSION) === 'yaml') {
217 if (strpos($persistenceIdentifier, 'EXT:') === 0) {
218 if (array_key_exists(pathinfo($persistenceIdentifier, PATHINFO_DIRNAME) . '/', $this->getAccessibleExtensionFolders())) {
219 $exists = file_exists(GeneralUtility::getFileAbsFileName($persistenceIdentifier));
220 }
221 } else {
222 list($storageUid, $fileIdentifier) = explode(':', $persistenceIdentifier, 2);
223 $storage = $this->getStorageByUid((int)$storageUid);
224 $exists = $storage->hasFile($fileIdentifier);
225 }
226 }
227 return $exists;
228 }
229
230 /**
231 * List all form definitions which can be loaded through this form persistence
232 * manager.
233 *
234 * Returns an associative array with each item containing the keys 'name' (the human-readable name of the form)
235 * and 'persistenceIdentifier' (the unique identifier for the Form Persistence Manager e.g. the path to the saved form definition).
236 *
237 * @return array in the format [['name' => 'Form 01', 'persistenceIdentifier' => 'path1'], [ .... ]]
238 * @internal
239 */
240 public function listForms(): array
241 {
242 $fileExtensionFilter = GeneralUtility::makeInstance(FileExtensionFilter::class);
243 $fileExtensionFilter->setAllowedFileExtensions(['yaml']);
244
245 $identifiers = [];
246 $forms = [];
247 /** @var \TYPO3\CMS\Core\Resource\Folder $folder */
248 foreach ($this->getAccessibleFormStorageFolders() as $folder) {
249 $storage = $folder->getStorage();
250 $storage->addFileAndFolderNameFilter([$fileExtensionFilter, 'filterFileList']);
251
252 // TODO: deprecated since TYPO3 v9, will be removed in TYPO3 v10
253 $formReadOnly = false;
254 if ($folder->getCombinedIdentifier() === '1:/user_upload/') {
255 $formReadOnly = true;
256 }
257
258 $files = $folder->getFiles(0, 0, Folder::FILTER_MODE_USE_OWN_AND_STORAGE_FILTERS, true);
259 foreach ($files as $file) {
260 $persistenceIdentifier = $storage->getUid() . ':' . $file->getIdentifier();
261
262 $form = $this->load($persistenceIdentifier);
263 if (isset($form['identifier'], $form['type']) && $form['type'] === 'Form') {
264 $forms[] = [
265 'identifier' => $form['identifier'],
266 'name' => $form['label'] ?? $form['identifier'],
267 'persistenceIdentifier' => $persistenceIdentifier,
268 'readOnly' => $formReadOnly,
269 'removable' => true,
270 'location' => 'storage',
271 'duplicateIdentifier' => false,
272 'invalid' => $form['invalid'],
273 'error' => $form['error'],
274 ];
275 $identifiers[$form['identifier']]++;
276 }
277 }
278 $storage->resetFileAndFolderNameFiltersToDefault();
279 }
280
281 foreach ($this->getAccessibleExtensionFolders() as $relativePath => $fullPath) {
282 $relativePath = rtrim($relativePath, '/') . '/';
283 foreach (new \DirectoryIterator($fullPath) as $fileInfo) {
284 if ($fileInfo->getExtension() !== 'yaml') {
285 continue;
286 }
287 $form = $this->load($relativePath . $fileInfo->getFilename());
288 if (isset($form['identifier'], $form['type']) && $form['type'] === 'Form') {
289 $forms[] = [
290 'identifier' => $form['identifier'],
291 'name' => $form['label'] ?? $form['identifier'],
292 'persistenceIdentifier' => $relativePath . $fileInfo->getFilename(),
293 'readOnly' => $this->formSettings['persistenceManager']['allowSaveToExtensionPaths'] ? false: true,
294 'removable' => $this->formSettings['persistenceManager']['allowDeleteFromExtensionPaths'] ? true: false,
295 'location' => 'extension',
296 'duplicateIdentifier' => false,
297 'invalid' => $form['invalid'],
298 'error' => $form['error'],
299 ];
300 $identifiers[$form['identifier']]++;
301 }
302 }
303 }
304
305 foreach ($identifiers as $identifier => $count) {
306 if ($count > 1) {
307 foreach ($forms as &$formDefinition) {
308 if ($formDefinition['identifier'] === $identifier) {
309 $formDefinition['duplicateIdentifier'] = true;
310 }
311 }
312 }
313 }
314
315 return $forms;
316 }
317
318 /**
319 * Return a list of all accessible file mountpoints for the
320 * current backend user.
321 *
322 * Only registered mountpoints from
323 * TYPO3.CMS.Form.persistenceManager.allowedFileMounts
324 * are listet.
325 *
326 * @return Folder[]
327 * @internal
328 */
329 public function getAccessibleFormStorageFolders(): array
330 {
331 $storageFolders = [];
332 if (
333 !isset($this->formSettings['persistenceManager']['allowedFileMounts'])
334 || !is_array($this->formSettings['persistenceManager']['allowedFileMounts'])
335 || empty($this->formSettings['persistenceManager']['allowedFileMounts'])
336 ) {
337 return $storageFolders;
338 }
339
340 foreach ($this->formSettings['persistenceManager']['allowedFileMounts'] as $allowedFileMount) {
341 list($storageUid, $fileMountIdentifier) = explode(':', $allowedFileMount, 2);
342 $fileMountIdentifier = rtrim($fileMountIdentifier, '/') . '/';
343
344 try {
345 $storage = $this->getStorageByUid((int)$storageUid);
346 } catch (PersistenceManagerException $e) {
347 continue;
348 }
349
350 try {
351 $folder = $storage->getFolder($fileMountIdentifier);
352 } catch (FolderDoesNotExistException $e) {
353 $storage->createFolder($fileMountIdentifier);
354 continue;
355 } catch (InsufficientFolderAccessPermissionsException $e) {
356 continue;
357 }
358 $storageFolders[$allowedFileMount] = $folder;
359 }
360 return $storageFolders;
361 }
362
363 /**
364 * Return a list of all accessible extension folders
365 *
366 * Only registered mountpoints from
367 * TYPO3.CMS.Form.persistenceManager.allowedExtensionPaths
368 * are listet.
369 *
370 * @return array
371 * @internal
372 */
373 public function getAccessibleExtensionFolders(): array
374 {
375 $extensionFolders = [];
376 if (
377 !isset($this->formSettings['persistenceManager']['allowedExtensionPaths'])
378 || !is_array($this->formSettings['persistenceManager']['allowedExtensionPaths'])
379 || empty($this->formSettings['persistenceManager']['allowedExtensionPaths'])
380 ) {
381 return $extensionFolders;
382 }
383
384 foreach ($this->formSettings['persistenceManager']['allowedExtensionPaths'] as $allowedExtensionPath) {
385 if (strpos($allowedExtensionPath, 'EXT:') !== 0) {
386 continue;
387 }
388
389 $allowedExtensionFullPath = GeneralUtility::getFileAbsFileName($allowedExtensionPath);
390 if (!file_exists($allowedExtensionFullPath)) {
391 continue;
392 }
393 $allowedExtensionPath = rtrim($allowedExtensionPath, '/') . '/';
394 $extensionFolders[$allowedExtensionPath] = $allowedExtensionFullPath;
395 }
396 return $extensionFolders;
397 }
398
399 /**
400 * This takes a form identifier and returns a unique persistence identifier for it.
401 * By default this is just similar to the identifier. But if a form with the same persistence identifier already
402 * exists a suffix is appended until the persistence identifier is unique.
403 *
404 * @param string $formIdentifier lowerCamelCased form identifier
405 * @param string $savePath
406 * @return string unique form persistence identifier
407 * @throws NoUniquePersistenceIdentifierException
408 * @internal
409 */
410 public function getUniquePersistenceIdentifier(string $formIdentifier, string $savePath): string
411 {
412 $savePath = rtrim($savePath, '/') . '/';
413 $formPersistenceIdentifier = $savePath . $formIdentifier . '.yaml';
414 if (!$this->exists($formPersistenceIdentifier)) {
415 return $formPersistenceIdentifier;
416 }
417 for ($attempts = 1; $attempts < 100; $attempts++) {
418 $formPersistenceIdentifier = $savePath . sprintf('%s_%d', $formIdentifier, $attempts) . '.yaml';
419 if (!$this->exists($formPersistenceIdentifier)) {
420 return $formPersistenceIdentifier;
421 }
422 }
423 $formPersistenceIdentifier = $savePath . sprintf('%s_%d', $formIdentifier, time()) . '.yaml';
424 if (!$this->exists($formPersistenceIdentifier)) {
425 return $formPersistenceIdentifier;
426 }
427
428 throw new NoUniquePersistenceIdentifierException(
429 sprintf('Could not find a unique persistence identifier for form identifier "%s" after %d attempts', $formIdentifier, $attempts),
430 1476010403
431 );
432 }
433
434 /**
435 * This takes a form identifier and returns a unique identifier for it.
436 * If a formDefinition with the same identifier already exists a suffix is
437 * appended until the identifier is unique.
438 *
439 * @param string $identifier
440 * @return string unique form identifier
441 * @throws NoUniqueIdentifierException
442 * @internal
443 */
444 public function getUniqueIdentifier(string $identifier): string
445 {
446 $originalIdentifier = $identifier;
447 if ($this->checkForDuplicateIdentifier($identifier)) {
448 for ($attempts = 1; $attempts < 100; $attempts++) {
449 $identifier = sprintf('%s_%d', $originalIdentifier, $attempts);
450 if (!$this->checkForDuplicateIdentifier($identifier)) {
451 return $identifier;
452 }
453 }
454 $identifier = $originalIdentifier . '_' . time();
455 if ($this->checkForDuplicateIdentifier($identifier)) {
456 throw new NoUniqueIdentifierException(
457 sprintf('Could not find a unique identifier for form identifier "%s" after %d attempts', $identifier, $attempts),
458 1477688567
459 );
460 }
461 }
462 return $identifier;
463 }
464
465 /**
466 * Check if a identifier is already used by a formDefintion.
467 *
468 * @param string $identifier
469 * @return bool
470 * @internal
471 */
472 public function checkForDuplicateIdentifier(string $identifier): bool
473 {
474 $identifierUsed = false;
475 foreach ($this->listForms() as $formDefinition) {
476 if ($formDefinition['identifier'] === $identifier) {
477 $identifierUsed = true;
478 break;
479 }
480 }
481 return $identifierUsed;
482 }
483
484 /**
485 * Returns a File object for a given $persistenceIdentifier
486 *
487 * @param string $persistenceIdentifier
488 * @return File
489 * @throws PersistenceManagerException
490 */
491 protected function getFileByIdentifier(string $persistenceIdentifier): File
492 {
493 list($storageUid, $fileIdentifier) = explode(':', $persistenceIdentifier, 2);
494 $storage = $this->getStorageByUid((int)$storageUid);
495 $file = $storage->getFile($fileIdentifier);
496 if (!$storage->checkFileActionPermission('read', $file)) {
497 throw new PersistenceManagerException(sprintf('No read access to file "%s".', $persistenceIdentifier), 1471630578);
498 }
499 return $file;
500 }
501
502 /**
503 * Returns a File object for a given $persistenceIdentifier.
504 * If no file for this identifier exists a new object will be
505 * created.
506 *
507 * @param string $persistenceIdentifier
508 * @return File
509 * @throws PersistenceManagerException
510 */
511 protected function getOrCreateFile(string $persistenceIdentifier): File
512 {
513 list($storageUid, $fileIdentifier) = explode(':', $persistenceIdentifier, 2);
514 $storage = $this->getStorageByUid((int)$storageUid);
515 $pathinfo = PathUtility::pathinfo($fileIdentifier);
516
517 if (!$storage->hasFolder($pathinfo['dirname'])) {
518 throw new PersistenceManagerException(sprintf('Could not create folder "%s".', $pathinfo['dirname']), 1471630579);
519 }
520
521 try {
522 $folder = $storage->getFolder($pathinfo['dirname']);
523 } catch (InsufficientFolderAccessPermissionsException $e) {
524 throw new PersistenceManagerException(sprintf('No read access to folder "%s".', $pathinfo['dirname']), 1512583307);
525 }
526
527 if (!$storage->checkFolderActionPermission('write', $folder)) {
528 throw new PersistenceManagerException(sprintf('No write access to folder "%s".', $pathinfo['dirname']), 1471630580);
529 }
530
531 if (!$storage->hasFile($fileIdentifier)) {
532 $file = $folder->createFile($pathinfo['basename']);
533 } else {
534 $file = $storage->getFile($fileIdentifier);
535 }
536 return $file;
537 }
538
539 /**
540 * Returns a ResourceStorage for a given uid
541 *
542 * @param int $storageUid
543 * @return ResourceStorage
544 * @throws PersistenceManagerException
545 */
546 protected function getStorageByUid(int $storageUid): ResourceStorage
547 {
548 $storage = $this->storageRepository->findByUid($storageUid);
549 if (
550 !$storage instanceof ResourceStorage
551 || !$storage->isBrowsable()
552 ) {
553 throw new PersistenceManagerException(sprintf('Could not access storage with uid "%d".', $storageUid), 1471630581);
554 }
555 return $storage;
556 }
557 }