Test Setup Failed
Push — master ( 217cdb...a3c3e9 )
by
unknown
19:17
created

FormRuntime::setFormDefinition()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 3
rs 10
c 0
b 0
f 0
cc 1
nc 1
nop 1
1
<?php
2
3
declare(strict_types=1);
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
18
/*
19
 * Inspired by and partially taken from the Neos.Form package (www.neos.io)
20
 */
21
22
namespace TYPO3\CMS\Form\Domain\Runtime;
23
24
use Psr\Container\ContainerInterface;
25
use Psr\Http\Message\ResponseInterface;
26
use Psr\Http\Message\ServerRequestInterface;
27
use TYPO3\CMS\Core\Context\Context;
28
use TYPO3\CMS\Core\Error\Http\BadRequestException;
29
use TYPO3\CMS\Core\ExpressionLanguage\Resolver;
30
use TYPO3\CMS\Core\Http\ApplicationType;
31
use TYPO3\CMS\Core\Http\Response;
32
use TYPO3\CMS\Core\Http\ServerRequest;
33
use TYPO3\CMS\Core\Site\Entity\Site;
34
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
35
use TYPO3\CMS\Core\Utility\ArrayUtility;
36
use TYPO3\CMS\Core\Utility\Exception\MissingArrayPathException;
37
use TYPO3\CMS\Core\Utility\GeneralUtility;
38
use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
39
use TYPO3\CMS\Extbase\Error\Result;
40
use TYPO3\CMS\Extbase\Mvc\Controller\Arguments;
41
use TYPO3\CMS\Extbase\Mvc\Controller\ControllerContext;
42
use TYPO3\CMS\Extbase\Mvc\Request;
43
use TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder;
44
use TYPO3\CMS\Extbase\Object\ObjectManagerInterface;
45
use TYPO3\CMS\Extbase\Property\Exception as PropertyException;
46
use TYPO3\CMS\Extbase\Security\Cryptography\HashService;
47
use TYPO3\CMS\Extbase\Security\Exception\InvalidArgumentForHashGenerationException;
48
use TYPO3\CMS\Extbase\Security\Exception\InvalidHashException;
49
use TYPO3\CMS\Form\Domain\Exception\RenderingException;
50
use TYPO3\CMS\Form\Domain\Finishers\FinisherContext;
51
use TYPO3\CMS\Form\Domain\Finishers\FinisherInterface;
52
use TYPO3\CMS\Form\Domain\Model\FormDefinition;
53
use TYPO3\CMS\Form\Domain\Model\FormElements\FormElementInterface;
54
use TYPO3\CMS\Form\Domain\Model\FormElements\Page;
55
use TYPO3\CMS\Form\Domain\Model\Renderable\RootRenderableInterface;
56
use TYPO3\CMS\Form\Domain\Model\Renderable\VariableRenderableInterface;
57
use TYPO3\CMS\Form\Domain\Renderer\RendererInterface;
58
use TYPO3\CMS\Form\Domain\Runtime\Exception\PropertyMappingException;
59
use TYPO3\CMS\Form\Domain\Runtime\FormRuntime\FormSession;
60
use TYPO3\CMS\Form\Domain\Runtime\FormRuntime\Lifecycle\AfterFormStateInitializedInterface;
61
use TYPO3\CMS\Form\Exception as FormException;
62
use TYPO3\CMS\Form\Mvc\Validation\EmptyValidator;
63
use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
64
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
65
use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
66
67
/**
68
 * This class implements the *runtime logic* of a form, i.e. deciding which
69
 * page is shown currently, what the current values of the form are, trigger
70
 * validation and property mapping.
71
 *
72
 * You generally receive an instance of this class by calling {@link \TYPO3\CMS\Form\Domain\Model\FormDefinition::bind}.
73
 *
74
 * Rendering a Form
75
 * ================
76
 *
77
 * That's easy, just call render() on the FormRuntime:
78
 *
79
 * /---code php
80
 * $form = $formDefinition->bind($request);
81
 * $renderedForm = $form->render();
82
 * \---
83
 *
84
 * Accessing Form Values
85
 * =====================
86
 *
87
 * In order to get the values the user has entered into the form, you can access
88
 * this object like an array: If a form field with the identifier *firstName*
89
 * exists, you can do **$form['firstName']** to retrieve its current value.
90
 *
91
 * You can also set values in the same way.
92
 *
93
 * Rendering Internals
94
 * ===================
95
 *
96
 * The FormRuntime asks the FormDefinition about the configured Renderer
97
 * which should be used ({@link \TYPO3\CMS\Form\Domain\Model\FormDefinition::getRendererClassName}),
98
 * and then trigger render() on this Renderer.
99
 *
100
 * This makes it possible to declaratively define how a form should be rendered.
101
 *
102
 * Scope: frontend
103
 * **This class is NOT meant to be sub classed by developers.**
104
 *
105
 * @internal High cohesion to FormDefinition, may change any time
106
 * @todo: Declare final in v12
107
 */
108
class FormRuntime implements RootRenderableInterface, \ArrayAccess
109
{
110
    const HONEYPOT_NAME_SESSION_IDENTIFIER = 'tx_form_honeypot_name_';
111
112
    protected ContainerInterface $container;
113
    protected ObjectManagerInterface $objectManager;
114
    protected ?FormDefinition $formDefinition = null;
115
    protected ?Request $request = null;
116
    protected ResponseInterface $response;
117
    protected HashService $hashService;
118
    protected ConfigurationManagerInterface $configurationManager;
119
120
    /**
121
     * @var FormState
122
     */
123
    protected $formState;
124
125
    /**
126
     * Individual unique random form session identifier valid
127
     * for current user session. This value is not persisted server-side.
128
     *
129
     * @var FormSession|null
130
     */
131
    protected $formSession;
132
133
    /**
134
     * The current page is the page which will be displayed to the user
135
     * during rendering.
136
     *
137
     * If $currentPage is NULL, the *last* page has been submitted and
138
     * finishing actions need to take place. You should use $this->isAfterLastPage()
139
     * instead of explicitly checking for NULL.
140
     *
141
     * @var Page|null
142
     */
143
    protected $currentPage;
144
145
    /**
146
     * Reference to the page which has been shown on the last request (i.e.
147
     * we have to handle the submitted data from lastDisplayedPage)
148
     *
149
     * @var Page
150
     */
151
    protected $lastDisplayedPage;
152
153
    /**
154
     * The current site language configuration.
155
     *
156
     * @var SiteLanguage
157
     */
158
    protected $currentSiteLanguage;
159
160
    /**
161
     * Reference to the current running finisher
162
     *
163
     * @var FinisherInterface
164
     */
165
    protected $currentFinisher;
166
167
    public function __construct(
168
        ContainerInterface $container,
169
        ObjectManagerInterface $objectManager,
170
        ConfigurationManagerInterface $configurationManager,
171
        HashService $hashService
172
    ) {
173
        $this->container = $container;
174
        // @deprecated since v11, will be removed in v12
175
        $this->objectManager = $objectManager;
176
        $this->configurationManager = $configurationManager;
177
        $this->hashService = $hashService;
178
        $this->response = new Response();
179
    }
180
181
    public function setFormDefinition(FormDefinition $formDefinition)
182
    {
183
        $this->formDefinition = $formDefinition;
184
    }
185
186
    public function setRequest(Request $request)
187
    {
188
        $this->request = clone $request;
189
    }
190
191
    public function initialize()
192
    {
193
        $arguments = $this->request->getArguments();
0 ignored issues
show
Bug introduced by
The method getArguments() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

193
        /** @scrutinizer ignore-call */ 
194
        $arguments = $this->request->getArguments();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
194
        $formIdentifier = $this->formDefinition->getIdentifier();
0 ignored issues
show
Bug introduced by
The method getIdentifier() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

194
        /** @scrutinizer ignore-call */ 
195
        $formIdentifier = $this->formDefinition->getIdentifier();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
195
        if (isset($arguments[$formIdentifier])) {
196
            $this->request->setArguments($arguments[$formIdentifier]);
197
        }
198
199
        $this->initializeCurrentSiteLanguage();
200
        $this->initializeFormSessionFromRequest();
201
        $this->initializeFormStateFromRequest();
202
        $this->triggerAfterFormStateInitialized();
203
        $this->processVariants();
204
        $this->initializeCurrentPageFromRequest();
205
        $this->initializeHoneypotFromRequest();
206
207
        // Only validate and set form values within the form state
208
        // if the current request is not the very first request
209
        // and the current request can be processed (POST request and uncached).
210
        if (!$this->isFirstRequest() && $this->canProcessFormSubmission()) {
211
            $this->processSubmittedFormValues();
212
        }
213
214
        $this->renderHoneypot();
215
    }
216
217
    /**
218
     * @todo `FormRuntime::$formSession` is still vulnerable to session fixation unless a real cookie-based process is used
219
     */
220
    protected function initializeFormSessionFromRequest(): void
221
    {
222
        // Initialize the form session only if the current request can be processed
223
        // (POST request and uncached) to ensure unique sessions for each form submitter.
224
        if (!$this->canProcessFormSubmission()) {
225
            return;
226
        }
227
228
        $sessionIdentifierFromRequest = $this->request->getInternalArgument('__session');
229
        $this->formSession = GeneralUtility::makeInstance(FormSession::class, $sessionIdentifierFromRequest);
230
    }
231
232
    /**
233
     * Initializes the current state of the form, based on the request
234
     * @throws BadRequestException
235
     */
236
    protected function initializeFormStateFromRequest()
237
    {
238
        // Only try to reconstitute the form state if the current request
239
        // is not the very first request and if the current request can
240
        // be processed (POST request and uncached).
241
        $serializedFormStateWithHmac = $this->request->getInternalArgument('__state');
242
        if ($serializedFormStateWithHmac === null || !$this->canProcessFormSubmission()) {
243
            $this->formState = GeneralUtility::makeInstance(FormState::class);
244
        } else {
245
            try {
246
                $serializedFormState = $this->hashService->validateAndStripHmac($serializedFormStateWithHmac);
247
            } catch (InvalidHashException | InvalidArgumentForHashGenerationException $e) {
248
                throw new BadRequestException('The HMAC of the form state could not be validated.', 1581862823);
249
            }
250
            $this->formState = unserialize(base64_decode($serializedFormState));
251
        }
252
    }
253
254
    protected function triggerAfterFormStateInitialized(): void
255
    {
256
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['afterFormStateInitialized'] ?? [] as $className) {
257
            $hookObj = GeneralUtility::makeInstance($className);
258
            if ($hookObj instanceof AfterFormStateInitializedInterface) {
259
                $hookObj->afterFormStateInitialized($this);
260
            }
261
        }
262
    }
263
264
    /**
265
     * Initializes the current page data based on the current request, also modifiable by a hook
266
     */
267
    protected function initializeCurrentPageFromRequest()
268
    {
269
        // If there was no previous form submissions or if the current request
270
        // can't be processed (no POST request and/or cached) then display the first
271
        // form step
272
        if (!$this->formState->isFormSubmitted() || !$this->canProcessFormSubmission()) {
273
            $this->currentPage = $this->formDefinition->getPageByIndex(0);
274
275
            if (!$this->currentPage->isEnabled()) {
276
                throw new FormException('Disabling the first page is not allowed', 1527186844);
277
            }
278
279
            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['afterInitializeCurrentPage'] ?? [] as $className) {
280
                $hookObj = GeneralUtility::makeInstance($className);
281
                if (method_exists($hookObj, 'afterInitializeCurrentPage')) {
282
                    $this->currentPage = $hookObj->afterInitializeCurrentPage(
283
                        $this,
284
                        $this->currentPage,
285
                        null,
286
                        $this->request->getArguments()
287
                    );
288
                }
289
            }
290
            return;
291
        }
292
293
        $this->lastDisplayedPage = $this->formDefinition->getPageByIndex($this->formState->getLastDisplayedPageIndex());
294
        $currentPageIndex = (int)$this->request->getInternalArgument('__currentPage');
295
296
        if ($this->userWentBackToPreviousStep()) {
297
            if ($currentPageIndex < $this->lastDisplayedPage->getIndex()) {
298
                $currentPageIndex = $this->lastDisplayedPage->getIndex();
299
            }
300
        } else {
301
            if ($currentPageIndex > $this->lastDisplayedPage->getIndex() + 1) {
302
                $currentPageIndex = $this->lastDisplayedPage->getIndex() + 1;
303
            }
304
        }
305
306
        if ($currentPageIndex >= count($this->formDefinition->getPages())) {
307
            // Last Page
308
            $this->currentPage = null;
309
        } else {
310
            $this->currentPage = $this->formDefinition->getPageByIndex($currentPageIndex);
311
312
            if (!$this->currentPage->isEnabled()) {
313
                if ($currentPageIndex === 0) {
314
                    throw new FormException('Disabling the first page is not allowed', 1527186845);
315
                }
316
317
                if ($this->userWentBackToPreviousStep()) {
318
                    $this->currentPage = $this->getPreviousEnabledPage();
319
                } else {
320
                    $this->currentPage = $this->getNextEnabledPage();
321
                }
322
            }
323
        }
324
325
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['afterInitializeCurrentPage'] ?? [] as $className) {
326
            $hookObj = GeneralUtility::makeInstance($className);
327
            if (method_exists($hookObj, 'afterInitializeCurrentPage')) {
328
                $this->currentPage = $hookObj->afterInitializeCurrentPage(
329
                    $this,
330
                    $this->currentPage,
331
                    $this->lastDisplayedPage,
332
                    $this->request->getArguments()
333
                );
334
            }
335
        }
336
    }
337
338
    /**
339
     * Checks if the honey pot is active, and adds a validator if so.
340
     */
341
    protected function initializeHoneypotFromRequest()
342
    {
343
        $renderingOptions = $this->formDefinition->getRenderingOptions();
344
        if (!isset($renderingOptions['honeypot']['enable'])
345
            || $renderingOptions['honeypot']['enable'] === false
346
            || (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface
347
                && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isBackend())
348
        ) {
349
            return;
350
        }
351
352
        ArrayUtility::assertAllArrayKeysAreValid($renderingOptions['honeypot'], ['enable', 'formElementToUse']);
353
354
        if (!$this->isFirstRequest()) {
355
            $elementsCount = count($this->lastDisplayedPage->getElements());
356
            if ($elementsCount === 0) {
357
                return;
358
            }
359
360
            $honeypotNameFromSession = $this->getHoneypotNameFromSession($this->lastDisplayedPage);
361
            if ($honeypotNameFromSession) {
362
                $honeypotElement = $this->lastDisplayedPage->createElement($honeypotNameFromSession, $renderingOptions['honeypot']['formElementToUse']);
363
                $validator = GeneralUtility::makeInstance(EmptyValidator::class);
364
                $honeypotElement->addValidator($validator);
365
            }
366
        }
367
    }
368
369
    /**
370
     * Renders a hidden field if the honey pot is active.
371
     */
372
    protected function renderHoneypot()
373
    {
374
        $renderingOptions = $this->formDefinition->getRenderingOptions();
375
        if (!isset($renderingOptions['honeypot']['enable'])
376
            || $this->currentPage === null
377
            || $renderingOptions['honeypot']['enable'] === false
378
            || (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface
379
                && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isBackend())
380
        ) {
381
            return;
382
        }
383
384
        ArrayUtility::assertAllArrayKeysAreValid($renderingOptions['honeypot'], ['enable', 'formElementToUse']);
385
386
        if (!$this->isAfterLastPage()) {
387
            $elementsCount = count($this->currentPage->getElements());
388
            if ($elementsCount === 0) {
389
                return;
390
            }
391
392
            if (!$this->isFirstRequest()) {
393
                $honeypotNameFromSession = $this->getHoneypotNameFromSession($this->lastDisplayedPage);
394
                if ($honeypotNameFromSession) {
395
                    $honeypotElement = $this->formDefinition->getElementByIdentifier($honeypotNameFromSession);
396
                    if ($honeypotElement instanceof FormElementInterface) {
0 ignored issues
show
introduced by
$honeypotElement is always a sub-type of TYPO3\CMS\Form\Domain\Mo...ts\FormElementInterface.
Loading history...
397
                        $this->lastDisplayedPage->removeElement($honeypotElement);
398
                    }
399
                }
400
            }
401
402
            $elementsCount = count($this->currentPage->getElements());
403
            $randomElementNumber = random_int(0, $elementsCount - 1);
404
            $honeypotName = substr(str_shuffle('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'), 0, random_int(5, 26));
405
406
            $referenceElement = $this->currentPage->getElements()[$randomElementNumber];
407
            $honeypotElement = $this->currentPage->createElement($honeypotName, $renderingOptions['honeypot']['formElementToUse']);
408
            $validator = GeneralUtility::makeInstance(EmptyValidator::class);
409
410
            $honeypotElement->addValidator($validator);
411
            if (random_int(0, 1) === 1) {
412
                $this->currentPage->moveElementAfter($honeypotElement, $referenceElement);
413
            } else {
414
                $this->currentPage->moveElementBefore($honeypotElement, $referenceElement);
415
            }
416
            $this->setHoneypotNameInSession($this->currentPage, $honeypotName);
417
        }
418
    }
419
420
    /**
421
     * @param Page $page
422
     * @return string|null
423
     */
424
    protected function getHoneypotNameFromSession(Page $page)
425
    {
426
        if ($this->isFrontendUserAuthenticated()) {
427
            $honeypotNameFromSession = $this->getFrontendUser()->getKey(
428
                'user',
429
                self::HONEYPOT_NAME_SESSION_IDENTIFIER . $this->getIdentifier() . $page->getIdentifier()
430
            );
431
        } else {
432
            $honeypotNameFromSession = $this->getFrontendUser()->getKey(
433
                'ses',
434
                self::HONEYPOT_NAME_SESSION_IDENTIFIER . $this->getIdentifier() . $page->getIdentifier()
435
            );
436
        }
437
        return $honeypotNameFromSession;
438
    }
439
440
    /**
441
     * @param Page $page
442
     * @param string $honeypotName
443
     */
444
    protected function setHoneypotNameInSession(Page $page, string $honeypotName)
445
    {
446
        if ($this->isFrontendUserAuthenticated()) {
447
            $this->getFrontendUser()->setKey(
448
                'user',
449
                self::HONEYPOT_NAME_SESSION_IDENTIFIER . $this->getIdentifier() . $page->getIdentifier(),
450
                $honeypotName
451
            );
452
        } else {
453
            $this->getFrontendUser()->setKey(
454
                'ses',
455
                self::HONEYPOT_NAME_SESSION_IDENTIFIER . $this->getIdentifier() . $page->getIdentifier(),
456
                $honeypotName
457
            );
458
        }
459
    }
460
461
    /**
462
     * Necessary to know if honeypot information should be stored in the user session info, or in the anonymous session
463
     *
464
     * @return bool true when a frontend user is logged, otherwise false
465
     */
466
    protected function isFrontendUserAuthenticated(): bool
467
    {
468
        return (bool)GeneralUtility::makeInstance(Context::class)
469
            ->getPropertyFromAspect('frontend.user', 'isLoggedIn', false);
470
    }
471
472
    protected function processVariants()
473
    {
474
        $conditionResolver = $this->getConditionResolver();
475
476
        $renderables = array_merge([$this->formDefinition], $this->formDefinition->getRenderablesRecursively());
477
        foreach ($renderables as $renderable) {
478
            if ($renderable instanceof VariableRenderableInterface) {
479
                $variants = $renderable->getVariants();
480
                foreach ($variants as $variant) {
481
                    if ($variant->conditionMatches($conditionResolver)) {
482
                        $variant->apply();
483
                    }
484
                }
485
            }
486
        }
487
    }
488
489
    /**
490
     * Returns TRUE if the last page of the form has been submitted, otherwise FALSE
491
     *
492
     * @return bool
493
     */
494
    protected function isAfterLastPage(): bool
495
    {
496
        return $this->currentPage === null;
497
    }
498
499
    /**
500
     * Returns TRUE if no previous page is stored in the FormState, otherwise FALSE
501
     *
502
     * @return bool
503
     */
504
    protected function isFirstRequest(): bool
505
    {
506
        return $this->lastDisplayedPage === null;
507
    }
508
509
    /**
510
     * @return bool
511
     */
512
    protected function isPostRequest(): bool
513
    {
514
        return $this->getRequest()->getMethod() === 'POST';
515
    }
516
517
    /**
518
     * Determine whether the surrounding content object is cached.
519
     * If no surrounding content object can be found (which would be strange)
520
     * we assume a cached request for safety which means that an empty form
521
     * will be rendered.
522
     *
523
     * @todo: this should be checked against https://forge.typo3.org/issues/91625 as this was fixed differently for UriBuilder
524
     * @return bool
525
     */
526
    protected function isRenderedCached(): bool
527
    {
528
        $contentObject = $this->configurationManager->getContentObject();
529
        return $contentObject === null
530
            ? true
531
            // @todo this does not work when rendering a cached `FLUIDTEMPLATE` (not nested in `COA_INT`)
532
            : $contentObject->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER;
533
    }
534
535
    /**
536
     * Runs through all validations
537
     */
538
    protected function processSubmittedFormValues()
539
    {
540
        $result = $this->mapAndValidatePage($this->lastDisplayedPage);
541
        if ($result->hasErrors() && !$this->userWentBackToPreviousStep()) {
542
            $this->currentPage = $this->lastDisplayedPage;
543
            $this->request->setOriginalRequestMappingResults($result);
544
        }
545
    }
546
547
    /**
548
     * returns TRUE if the user went back to any previous step in the form.
549
     *
550
     * @return bool
551
     */
552
    protected function userWentBackToPreviousStep(): bool
553
    {
554
        return !$this->isAfterLastPage() && !$this->isFirstRequest() && $this->currentPage->getIndex() < $this->lastDisplayedPage->getIndex();
555
    }
556
557
    /**
558
     * @param Page $page
559
     * @return Result
560
     * @throws PropertyMappingException
561
     */
562
    protected function mapAndValidatePage(Page $page): Result
563
    {
564
        $result = GeneralUtility::makeInstance(Result::class);
565
        $requestArguments = $this->request->getArguments();
566
567
        $propertyPathsForWhichPropertyMappingShouldHappen = [];
568
        $registerPropertyPaths = function ($propertyPath) use (&$propertyPathsForWhichPropertyMappingShouldHappen) {
569
            $propertyPathParts = explode('.', $propertyPath);
570
            $accumulatedPropertyPathParts = [];
571
            foreach ($propertyPathParts as $propertyPathPart) {
572
                $accumulatedPropertyPathParts[] = $propertyPathPart;
573
                $temporaryPropertyPath = implode('.', $accumulatedPropertyPathParts);
574
                $propertyPathsForWhichPropertyMappingShouldHappen[$temporaryPropertyPath] = $temporaryPropertyPath;
575
            }
576
        };
577
578
        $value = null;
579
580
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['afterSubmit'] ?? [] as $className) {
581
            $hookObj = GeneralUtility::makeInstance($className);
582
            if (method_exists($hookObj, 'afterSubmit')) {
583
                $value = $hookObj->afterSubmit(
584
                    $this,
585
                    $page,
586
                    $value,
587
                    $requestArguments
588
                );
589
            }
590
        }
591
592
        foreach ($page->getElementsRecursively() as $element) {
593
            if (!$element->isEnabled()) {
594
                continue;
595
            }
596
597
            try {
598
                $value = ArrayUtility::getValueByPath($requestArguments, $element->getIdentifier(), '.');
599
            } catch (MissingArrayPathException $exception) {
600
                $value = null;
601
            }
602
603
            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['afterSubmit'] ?? [] as $className) {
604
                $hookObj = GeneralUtility::makeInstance($className);
605
                if (method_exists($hookObj, 'afterSubmit')) {
606
                    $value = $hookObj->afterSubmit(
607
                        $this,
608
                        $element,
609
                        $value,
610
                        $requestArguments
611
                    );
612
                }
613
            }
614
615
            $this->formState->setFormValue($element->getIdentifier(), $value);
616
            $registerPropertyPaths($element->getIdentifier());
617
        }
618
619
        // The more parts the path has, the more early it is processed
620
        usort($propertyPathsForWhichPropertyMappingShouldHappen, function ($a, $b) {
621
            return substr_count($b, '.') - substr_count($a, '.');
622
        });
623
624
        $processingRules = $this->formDefinition->getProcessingRules();
625
626
        foreach ($propertyPathsForWhichPropertyMappingShouldHappen as $propertyPath) {
627
            if (isset($processingRules[$propertyPath])) {
628
                $processingRule = $processingRules[$propertyPath];
629
                $value = $this->formState->getFormValue($propertyPath);
630
                try {
631
                    $value = $processingRule->process($value);
632
                } catch (PropertyException $exception) {
633
                    throw new PropertyMappingException(
634
                        'Failed to process FormValue at "' . $propertyPath . '" from "' . gettype($value) . '" to "' . $processingRule->getDataType() . '"',
635
                        1480024933,
636
                        $exception
637
                    );
638
                }
639
                $result->forProperty($this->getIdentifier() . '.' . $propertyPath)->merge($processingRule->getProcessingMessages());
640
                $this->formState->setFormValue($propertyPath, $value);
641
            }
642
        }
643
644
        return $result;
645
    }
646
647
    /**
648
     * Override the current page taken from the request, rendering the page with index $pageIndex instead.
649
     *
650
     * This is typically not needed in production code, but it is very helpful when displaying
651
     * some kind of "preview" of the form (e.g. form editor).
652
     *
653
     * @param int $pageIndex
654
     */
655
    public function overrideCurrentPage(int $pageIndex)
656
    {
657
        $this->currentPage = $this->formDefinition->getPageByIndex($pageIndex);
658
    }
659
660
    /**
661
     * Render this form.
662
     *
663
     * @return string|null rendered form
664
     * @throws RenderingException
665
     */
666
    public function render()
667
    {
668
        if ($this->isAfterLastPage()) {
669
            return $this->invokeFinishers();
670
        }
671
        $this->processVariants();
672
673
        $this->formState->setLastDisplayedPageIndex($this->currentPage->getIndex());
674
675
        if ($this->formDefinition->getRendererClassName() === '') {
676
            throw new RenderingException(sprintf('The form definition "%s" does not have a rendererClassName set.', $this->formDefinition->getIdentifier()), 1326095912);
677
        }
678
        $rendererClassName = $this->formDefinition->getRendererClassName();
679
        if ($this->container->has($rendererClassName)) {
680
            $renderer = $this->container->get($rendererClassName);
681
        } else {
682
            // @deprecated since v11, will be removed in v12.
683
            $renderer = $this->objectManager->get($rendererClassName);
684
        }
685
        if (!($renderer instanceof RendererInterface)) {
686
            throw new RenderingException(sprintf('The renderer "%s" des not implement RendererInterface', $rendererClassName), 1326096024);
687
        }
688
689
        $controllerContext = $this->getControllerContext();
690
691
        $renderer->setControllerContext($controllerContext);
692
        $renderer->setFormRuntime($this);
693
        return $renderer->render();
694
    }
695
696
    /**
697
     * Executes all finishers of this form
698
     *
699
     * @return string
700
     */
701
    protected function invokeFinishers(): string
702
    {
703
        $finisherContext = GeneralUtility::makeInstance(
704
            FinisherContext::class,
705
            $this,
706
            $this->getControllerContext(),
707
            $this->request
708
        );
709
710
        $output = '';
711
        $this->response->getBody()->rewind();
712
        $originalContent = $this->response->getBody()->getContents();
713
        $this->response->getBody()->write('');
714
        foreach ($this->formDefinition->getFinishers() as $finisher) {
715
            $this->currentFinisher = $finisher;
716
            $this->processVariants();
717
718
            $finisherOutput = $finisher->execute($finisherContext);
719
            if (is_string($finisherOutput) && !empty($finisherOutput)) {
720
                $output .= $finisherOutput;
721
            } else {
722
                $this->response->getBody()->rewind();
723
                $output .= $this->response->getBody()->getContents();
724
                $this->response->getBody()->write('');
725
            }
726
727
            if ($finisherContext->isCancelled()) {
728
                break;
729
            }
730
        }
731
        $this->response->getBody()->rewind();
732
        $this->response->getBody()->write($originalContent);
733
734
        return $output;
735
    }
736
737
    /**
738
     * @return string The identifier of underlying form
739
     */
740
    public function getIdentifier(): string
741
    {
742
        return $this->formDefinition->getIdentifier();
743
    }
744
745
    /**
746
     * Get the request this object is bound to.
747
     *
748
     * This is mostly relevant inside Finishers, where you f.e. want to redirect
749
     * the user to another page.
750
     *
751
     * @return Request the request this object is bound to
752
     */
753
    public function getRequest(): Request
754
    {
755
        return $this->request;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->request could return the type null which is incompatible with the type-hinted return TYPO3\CMS\Extbase\Mvc\Request. Consider adding an additional type-check to rule them out.
Loading history...
756
    }
757
758
    /**
759
     * Get the response this object is bound to.
760
     *
761
     * This is mostly relevant inside Finishers, where you f.e. want to set response
762
     * headers or output content.
763
     *
764
     * @return ResponseInterface the response this object is bound to
765
     */
766
    public function getResponse(): ResponseInterface
767
    {
768
        return $this->response;
769
    }
770
771
    /**
772
     * Only process values if there is a post request and if the
773
     * surrounding content object is uncached.
774
     * Is this not the case, all possible submitted values will be discarded
775
     * and the first form step will be shown with an empty form state.
776
     *
777
     * @return bool
778
     * @internal
779
     */
780
    public function canProcessFormSubmission(): bool
781
    {
782
        return $this->isPostRequest() && !$this->isRenderedCached();
783
    }
784
785
    /**
786
     * @return FormSession|null
787
     * @internal
788
     */
789
    public function getFormSession(): ?FormSession
790
    {
791
        return $this->formSession;
792
    }
793
794
    /**
795
     * Returns the currently selected page
796
     *
797
     * @return Page|null
798
     */
799
    public function getCurrentPage(): ?Page
800
    {
801
        return $this->currentPage;
802
    }
803
804
    /**
805
     * Returns the previous page of the currently selected one or NULL if there is no previous page
806
     *
807
     * @return Page|null
808
     */
809
    public function getPreviousPage(): ?Page
810
    {
811
        $previousPageIndex = $this->currentPage->getIndex() - 1;
0 ignored issues
show
Bug introduced by
The method getIndex() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

811
        $previousPageIndex = $this->currentPage->/** @scrutinizer ignore-call */ getIndex() - 1;

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
812
        if ($this->formDefinition->hasPageWithIndex($previousPageIndex)) {
813
            return $this->formDefinition->getPageByIndex($previousPageIndex);
814
        }
815
        return null;
816
    }
817
818
    /**
819
     * Returns the next page of the currently selected one or NULL if there is no next page
820
     *
821
     * @return Page|null
822
     */
823
    public function getNextPage(): ?Page
824
    {
825
        $nextPageIndex = $this->currentPage->getIndex() + 1;
826
        if ($this->formDefinition->hasPageWithIndex($nextPageIndex)) {
827
            return $this->formDefinition->getPageByIndex($nextPageIndex);
828
        }
829
        return null;
830
    }
831
832
    /**
833
     * Returns the previous enabled page of the currently selected one
834
     * or NULL if there is no previous page
835
     *
836
     * @return Page|null
837
     */
838
    public function getPreviousEnabledPage(): ?Page
839
    {
840
        $previousPage = null;
841
        $previousPageIndex = $this->currentPage->getIndex() - 1;
842
        while ($previousPageIndex >= 0) {
843
            if ($this->formDefinition->hasPageWithIndex($previousPageIndex)) {
844
                $previousPage = $this->formDefinition->getPageByIndex($previousPageIndex);
845
846
                if ($previousPage->isEnabled()) {
847
                    break;
848
                }
849
850
                $previousPage = null;
851
                $previousPageIndex--;
852
            } else {
853
                $previousPage = null;
854
                break;
855
            }
856
        }
857
858
        return $previousPage;
859
    }
860
861
    /**
862
     * Returns the next enabled page of the currently selected one or
863
     * NULL if there is no next page
864
     *
865
     * @return Page|null
866
     */
867
    public function getNextEnabledPage(): ?Page
868
    {
869
        $nextPage = null;
870
        $pageCount = count($this->formDefinition->getPages());
871
        $nextPageIndex = $this->currentPage->getIndex() + 1;
872
873
        while ($nextPageIndex < $pageCount) {
874
            if ($this->formDefinition->hasPageWithIndex($nextPageIndex)) {
875
                $nextPage = $this->formDefinition->getPageByIndex($nextPageIndex);
876
                $renderingOptions = $nextPage->getRenderingOptions();
877
                if (
878
                    !isset($renderingOptions['enabled'])
879
                    || (bool)$renderingOptions['enabled']
880
                ) {
881
                    break;
882
                }
883
                $nextPage = null;
884
                $nextPageIndex++;
885
            } else {
886
                $nextPage = null;
887
                break;
888
            }
889
        }
890
891
        return $nextPage;
892
    }
893
894
    /**
895
     * @return ControllerContext
896
     */
897
    protected function getControllerContext(): ControllerContext
898
    {
899
        $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class);
900
        $uriBuilder->setRequest($this->request);
901
        $controllerContext = GeneralUtility::makeInstance(ControllerContext::class);
902
        $controllerContext->setRequest($this->request);
903
        $controllerContext->setArguments(GeneralUtility::makeInstance(Arguments::class));
904
        $controllerContext->setUriBuilder($uriBuilder);
905
        return $controllerContext;
906
    }
907
908
    /**
909
     * Abstract "type" of this Renderable. Is used during the rendering process
910
     * to determine the template file or the View PHP class being used to render
911
     * the particular element.
912
     *
913
     * @return string
914
     */
915
    public function getType(): string
916
    {
917
        return $this->formDefinition->getType();
918
    }
919
920
    /**
921
     * @param string $identifier
922
     * @return bool
923
     * @internal
924
     */
925
    public function offsetExists($identifier)
926
    {
927
        if ($this->getElementValue($identifier) !== null) {
928
            return true;
929
        }
930
931
        if (is_callable([$this, 'get' . ucfirst($identifier)])) {
932
            return true;
933
        }
934
        if (is_callable([$this, 'has' . ucfirst($identifier)])) {
935
            return true;
936
        }
937
        if (is_callable([$this, 'is' . ucfirst($identifier)])) {
938
            return true;
939
        }
940
        if (property_exists($this, $identifier)) {
941
            $propertyReflection = new \ReflectionProperty($this, $identifier);
942
            return $propertyReflection->isPublic();
943
        }
944
945
        return false;
946
    }
947
948
    /**
949
     * @param string $identifier
950
     * @return mixed
951
     * @internal
952
     */
953
    public function offsetGet($identifier)
954
    {
955
        if ($this->getElementValue($identifier) !== null) {
956
            return $this->getElementValue($identifier);
957
        }
958
        $getterMethodName = 'get' . ucfirst($identifier);
959
        if (is_callable([$this, $getterMethodName])) {
960
            return $this->{$getterMethodName}();
961
        }
962
        return null;
963
    }
964
965
    /**
966
     * @param string $identifier
967
     * @param mixed $value
968
     * @internal
969
     */
970
    public function offsetSet($identifier, $value)
971
    {
972
        $this->formState->setFormValue($identifier, $value);
973
    }
974
975
    /**
976
     * @param string $identifier
977
     * @internal
978
     */
979
    public function offsetUnset($identifier)
980
    {
981
        $this->formState->setFormValue($identifier, null);
982
    }
983
984
    /**
985
     * Returns the value of the specified element
986
     *
987
     * @param string $identifier
988
     * @return mixed
989
     */
990
    public function getElementValue(string $identifier)
991
    {
992
        $formValue = $this->formState->getFormValue($identifier);
993
        if ($formValue !== null) {
994
            return $formValue;
995
        }
996
        return $this->formDefinition->getElementDefaultValueByIdentifier($identifier);
997
    }
998
999
    /**
1000
     * @return array|Page[] The Form's pages in the correct order
1001
     */
1002
    public function getPages(): array
1003
    {
1004
        return $this->formDefinition->getPages();
1005
    }
1006
1007
    /**
1008
     * @return FormState|null
1009
     * @internal
1010
     */
1011
    public function getFormState(): ?FormState
1012
    {
1013
        return $this->formState;
1014
    }
1015
1016
    /**
1017
     * Get all rendering options
1018
     *
1019
     * @return array associative array of rendering options
1020
     */
1021
    public function getRenderingOptions(): array
1022
    {
1023
        return $this->formDefinition->getRenderingOptions();
1024
    }
1025
1026
    /**
1027
     * Get the renderer class name to be used to display this renderable;
1028
     * must implement RendererInterface
1029
     *
1030
     * @return string the renderer class name
1031
     */
1032
    public function getRendererClassName(): string
1033
    {
1034
        return $this->formDefinition->getRendererClassName();
1035
    }
1036
1037
    /**
1038
     * Get the label which shall be displayed next to the form element
1039
     *
1040
     * @return string
1041
     */
1042
    public function getLabel(): string
1043
    {
1044
        return $this->formDefinition->getLabel();
1045
    }
1046
1047
    /**
1048
     * Get the template name of the renderable
1049
     *
1050
     * @return string
1051
     */
1052
    public function getTemplateName(): string
1053
    {
1054
        return $this->formDefinition->getTemplateName();
1055
    }
1056
1057
    /**
1058
     * Get the underlying form definition from the runtime
1059
     *
1060
     * @return FormDefinition
1061
     */
1062
    public function getFormDefinition(): FormDefinition
1063
    {
1064
        return $this->formDefinition;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->formDefinition could return the type null which is incompatible with the type-hinted return TYPO3\CMS\Form\Domain\Model\FormDefinition. Consider adding an additional type-check to rule them out.
Loading history...
1065
    }
1066
1067
    /**
1068
     * Get the current site language configuration.
1069
     *
1070
     * @return SiteLanguage
1071
     */
1072
    public function getCurrentSiteLanguage(): ?SiteLanguage
1073
    {
1074
        return $this->currentSiteLanguage;
1075
    }
1076
1077
    /**
1078
     * Override the the current site language configuration.
1079
     *
1080
     * This is typically not needed in production code, but it is very
1081
     * helpful when displaying some kind of "preview" of the form (e.g. form editor).
1082
     *
1083
     * @param SiteLanguage $currentSiteLanguage
1084
     */
1085
    public function setCurrentSiteLanguage(SiteLanguage $currentSiteLanguage): void
1086
    {
1087
        $this->currentSiteLanguage = $currentSiteLanguage;
1088
    }
1089
1090
    /**
1091
     * Initialize the SiteLanguage object.
1092
     * This is mainly used by the condition matcher.
1093
     */
1094
    protected function initializeCurrentSiteLanguage(): void
1095
    {
1096
        if (
1097
            $GLOBALS['TYPO3_REQUEST'] instanceof ServerRequestInterface
1098
            && $GLOBALS['TYPO3_REQUEST']->getAttribute('language') instanceof SiteLanguage
1099
        ) {
1100
            $this->currentSiteLanguage = $GLOBALS['TYPO3_REQUEST']->getAttribute('language');
1101
        } else {
1102
            $pageId = 0;
1103
            $languageId = (int)GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('language', 'id', 0);
1104
1105
            if ($this->getTypoScriptFrontendController() !== null) {
1106
                $pageId = $this->getTypoScriptFrontendController()->id;
1107
            }
1108
1109
            $fakeSiteConfiguration = [
1110
                'languages' => [
1111
                    [
1112
                        'languageId' => $languageId,
1113
                        'title' => 'Dummy',
1114
                        'navigationTitle' => '',
1115
                        'typo3Language' => '',
1116
                        'flag' => '',
1117
                        'locale' => '',
1118
                        'iso-639-1' => '',
1119
                        'hreflang' => '',
1120
                        'direction' => '',
1121
                    ],
1122
                ],
1123
            ];
1124
1125
            $this->currentSiteLanguage = GeneralUtility::makeInstance(Site::class, 'form-dummy', $pageId, $fakeSiteConfiguration)
1126
                ->getLanguageById($languageId);
1127
        }
1128
    }
1129
1130
    /**
1131
     * Reference to the current running finisher
1132
     *
1133
     * @return FinisherInterface|null
1134
     */
1135
    public function getCurrentFinisher(): ?FinisherInterface
1136
    {
1137
        return $this->currentFinisher;
1138
    }
1139
1140
    /**
1141
     * @return Resolver
1142
     */
1143
    protected function getConditionResolver(): Resolver
1144
    {
1145
        $formValues = array_replace_recursive(
1146
            $this->getFormState()->getFormValues(),
1147
            $this->getRequest()->getArguments()
1148
        );
1149
        $page = $this->getCurrentPage() ?? $this->getFormDefinition()->getPageByIndex(0);
1150
1151
        $finisherIdentifier = '';
1152
        if ($this->getCurrentFinisher() !== null) {
1153
            if (method_exists($this->getCurrentFinisher(), 'getFinisherIdentifier')) {
1154
                $finisherIdentifier = $this->getCurrentFinisher()->getFinisherIdentifier();
1155
            } else {
1156
                $finisherIdentifier = (new \ReflectionClass($this->getCurrentFinisher()))->getShortName();
1157
                $finisherIdentifier = preg_replace('/Finisher$/', '', $finisherIdentifier);
1158
            }
1159
        }
1160
1161
        return GeneralUtility::makeInstance(
1162
            Resolver::class,
1163
            'form',
1164
            [
1165
                // some shortcuts
1166
                'formRuntime' => $this,
1167
                'formValues' => $formValues,
1168
                'stepIdentifier' => $page->getIdentifier(),
1169
                'stepType' => $page->getType(),
1170
                'finisherIdentifier' => $finisherIdentifier,
1171
            ],
1172
            $GLOBALS['TYPO3_REQUEST'] ?? GeneralUtility::makeInstance(ServerRequest::class)
1173
        );
1174
    }
1175
1176
    /**
1177
     * @return FrontendUserAuthentication
1178
     */
1179
    protected function getFrontendUser(): FrontendUserAuthentication
1180
    {
1181
        return $this->getTypoScriptFrontendController()->fe_user;
1182
    }
1183
1184
    /**
1185
     * @return TypoScriptFrontendController|null
1186
     */
1187
    protected function getTypoScriptFrontendController(): ?TypoScriptFrontendController
1188
    {
1189
        return $GLOBALS['TSFE'] ?? null;
1190
    }
1191
}
1192