Passed
Push — master ( 6f0002...d39f54 )
by
unknown
19:15
created

FormRuntime::triggerAfterFormStateInitialized()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 4
c 0
b 0
f 0
dl 0
loc 6
rs 10
cc 3
nc 3
nop 0
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\Http\Message\ResponseInterface;
25
use Psr\Http\Message\ServerRequestInterface;
26
use TYPO3\CMS\Core\Context\Context;
27
use TYPO3\CMS\Core\Error\Http\BadRequestException;
28
use TYPO3\CMS\Core\ExpressionLanguage\Resolver;
29
use TYPO3\CMS\Core\Http\ApplicationType;
30
use TYPO3\CMS\Core\Http\ServerRequest;
31
use TYPO3\CMS\Core\Site\Entity\Site;
32
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
33
use TYPO3\CMS\Core\Utility\ArrayUtility;
34
use TYPO3\CMS\Core\Utility\Exception\MissingArrayPathException;
35
use TYPO3\CMS\Core\Utility\GeneralUtility;
36
use TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface;
37
use TYPO3\CMS\Extbase\Error\Result;
38
use TYPO3\CMS\Extbase\Mvc\Controller\Arguments;
39
use TYPO3\CMS\Extbase\Mvc\Controller\ControllerContext;
40
use TYPO3\CMS\Extbase\Mvc\Request;
41
use TYPO3\CMS\Extbase\Mvc\Web\Routing\UriBuilder;
42
use TYPO3\CMS\Extbase\Object\ObjectManagerInterface;
43
use TYPO3\CMS\Extbase\Property\Exception as PropertyException;
44
use TYPO3\CMS\Extbase\Security\Cryptography\HashService;
45
use TYPO3\CMS\Extbase\Security\Exception\InvalidArgumentForHashGenerationException;
46
use TYPO3\CMS\Extbase\Security\Exception\InvalidHashException;
47
use TYPO3\CMS\Form\Domain\Exception\RenderingException;
48
use TYPO3\CMS\Form\Domain\Finishers\FinisherContext;
49
use TYPO3\CMS\Form\Domain\Finishers\FinisherInterface;
50
use TYPO3\CMS\Form\Domain\Model\FormDefinition;
51
use TYPO3\CMS\Form\Domain\Model\FormElements\FormElementInterface;
52
use TYPO3\CMS\Form\Domain\Model\FormElements\Page;
53
use TYPO3\CMS\Form\Domain\Model\Renderable\RootRenderableInterface;
54
use TYPO3\CMS\Form\Domain\Model\Renderable\VariableRenderableInterface;
55
use TYPO3\CMS\Form\Domain\Renderer\RendererInterface;
56
use TYPO3\CMS\Form\Domain\Runtime\Exception\PropertyMappingException;
57
use TYPO3\CMS\Form\Domain\Runtime\FormRuntime\FormSession;
58
use TYPO3\CMS\Form\Domain\Runtime\FormRuntime\Lifecycle\AfterFormStateInitializedInterface;
59
use TYPO3\CMS\Form\Exception as FormException;
60
use TYPO3\CMS\Form\Mvc\Validation\EmptyValidator;
61
use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
62
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
63
use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
64
65
/**
66
 * This class implements the *runtime logic* of a form, i.e. deciding which
67
 * page is shown currently, what the current values of the form are, trigger
68
 * validation and property mapping.
69
 *
70
 * You generally receive an instance of this class by calling {@link \TYPO3\CMS\Form\Domain\Model\FormDefinition::bind}.
71
 *
72
 * Rendering a Form
73
 * ================
74
 *
75
 * That's easy, just call render() on the FormRuntime:
76
 *
77
 * /---code php
78
 * $form = $formDefinition->bind($request, $response);
79
 * $renderedForm = $form->render();
80
 * \---
81
 *
82
 * Accessing Form Values
83
 * =====================
84
 *
85
 * In order to get the values the user has entered into the form, you can access
86
 * this object like an array: If a form field with the identifier *firstName*
87
 * exists, you can do **$form['firstName']** to retrieve its current value.
88
 *
89
 * You can also set values in the same way.
90
 *
91
 * Rendering Internals
92
 * ===================
93
 *
94
 * The FormRuntime asks the FormDefinition about the configured Renderer
95
 * which should be used ({@link \TYPO3\CMS\Form\Domain\Model\FormDefinition::getRendererClassName}),
96
 * and then trigger render() on this Renderer.
97
 *
98
 * This makes it possible to declaratively define how a form should be rendered.
99
 *
100
 * Scope: frontend
101
 * **This class is NOT meant to be sub classed by developers.**
102
 */
103
class FormRuntime implements RootRenderableInterface, \ArrayAccess
104
{
105
    const HONEYPOT_NAME_SESSION_IDENTIFIER = 'tx_form_honeypot_name_';
106
107
    /**
108
     * @var \TYPO3\CMS\Extbase\Object\ObjectManagerInterface
109
     */
110
    protected $objectManager;
111
112
    /**
113
     * @var \TYPO3\CMS\Form\Domain\Model\FormDefinition
114
     */
115
    protected $formDefinition;
116
117
    /**
118
     * @var \TYPO3\CMS\Extbase\Mvc\Request
119
     */
120
    protected $request;
121
122
    /**
123
     * @var ResponseInterface
124
     */
125
    protected $response;
126
127
    /**
128
     * @var \TYPO3\CMS\Form\Domain\Runtime\FormState
129
     */
130
    protected $formState;
131
132
    /**
133
     * Individual unique random form session identifier valid
134
     * for current user session. This value is not persisted server-side.
135
     *
136
     * @var FormSession|null
137
     */
138
    protected $formSession;
139
140
    /**
141
     * The current page is the page which will be displayed to the user
142
     * during rendering.
143
     *
144
     * If $currentPage is NULL, the *last* page has been submitted and
145
     * finishing actions need to take place. You should use $this->isAfterLastPage()
146
     * instead of explicitly checking for NULL.
147
     *
148
     * @var \TYPO3\CMS\Form\Domain\Model\FormElements\Page|null
149
     */
150
    protected $currentPage;
151
152
    /**
153
     * Reference to the page which has been shown on the last request (i.e.
154
     * we have to handle the submitted data from lastDisplayedPage)
155
     *
156
     * @var \TYPO3\CMS\Form\Domain\Model\FormElements\Page
157
     */
158
    protected $lastDisplayedPage;
159
160
    /**
161
     * @var \TYPO3\CMS\Extbase\Security\Cryptography\HashService
162
     */
163
    protected $hashService;
164
165
    /**
166
     * The current site language configuration.
167
     *
168
     * @var SiteLanguage
169
     */
170
    protected $currentSiteLanguage;
171
172
    /**
173
     * Reference to the current running finisher
174
     *
175
     * @var \TYPO3\CMS\Form\Domain\Finishers\FinisherInterface
176
     */
177
    protected $currentFinisher;
178
179
    /**
180
     * @var \TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface
181
     */
182
    protected $configurationManager;
183
184
    /**
185
     * @param \TYPO3\CMS\Extbase\Security\Cryptography\HashService $hashService
186
     * @internal
187
     */
188
    public function injectHashService(HashService $hashService)
189
    {
190
        $this->hashService = $hashService;
191
    }
192
193
    /**
194
     * @param \TYPO3\CMS\Extbase\Object\ObjectManagerInterface $objectManager
195
     * @internal
196
     */
197
    public function injectObjectManager(ObjectManagerInterface $objectManager)
198
    {
199
        $this->objectManager = $objectManager;
200
    }
201
202
    /**
203
     * @param \TYPO3\CMS\Extbase\Configuration\ConfigurationManagerInterface $configurationManager
204
     */
205
    public function injectConfigurationManager(ConfigurationManagerInterface $configurationManager)
206
    {
207
        $this->configurationManager = $configurationManager;
208
    }
209
210
    /**
211
     * @param FormDefinition $formDefinition
212
     * @param Request $request
213
     * @param ResponseInterface $response
214
     */
215
    public function __construct(FormDefinition $formDefinition, Request $request, ResponseInterface $response)
216
    {
217
        $this->formDefinition = $formDefinition;
218
        $arguments = $request->getArguments();
219
        $this->request = clone $request;
220
        $formIdentifier = $this->formDefinition->getIdentifier();
221
        if (isset($arguments[$formIdentifier])) {
222
            $this->request->setArguments($arguments[$formIdentifier]);
223
        }
224
225
        $this->response = $response;
226
    }
227
228
    /**
229
     * @internal
230
     */
231
    public function initializeObject()
232
    {
233
        $this->initializeCurrentSiteLanguage();
234
        $this->initializeFormSessionFromRequest();
235
        $this->initializeFormStateFromRequest();
236
        $this->triggerAfterFormStateInitialized();
237
        $this->processVariants();
238
        $this->initializeCurrentPageFromRequest();
239
        $this->initializeHoneypotFromRequest();
240
241
        // Only validate and set form values within the form state
242
        // if the current request is not the very first request
243
        // and the current request can be processed (POST request and uncached).
244
        if (!$this->isFirstRequest() && $this->canProcessFormSubmission()) {
245
            $this->processSubmittedFormValues();
246
        }
247
248
        $this->renderHoneypot();
249
    }
250
251
    /**
252
     * @todo `FormRuntime::$formSession` is still vulnerable to session fixation unless a real cookie-based process is used
253
     */
254
    protected function initializeFormSessionFromRequest(): void
255
    {
256
        // Initialize the form session only if the current request can be processed
257
        // (POST request and uncached) to ensure unique sessions for each form submitter.
258
        if (!$this->canProcessFormSubmission()) {
259
            return;
260
        }
261
262
        $sessionIdentifierFromRequest = $this->request->getInternalArgument('__session');
263
        $this->formSession = GeneralUtility::makeInstance(FormSession::class, $sessionIdentifierFromRequest);
264
    }
265
266
    /**
267
     * Initializes the current state of the form, based on the request
268
     * @throws BadRequestException
269
     */
270
    protected function initializeFormStateFromRequest()
271
    {
272
        // Only try to reconstitute the form state if the current request
273
        // is not the very first request and if the current request can
274
        // be processed (POST request and uncached).
275
        $serializedFormStateWithHmac = $this->request->getInternalArgument('__state');
276
        if ($serializedFormStateWithHmac === null || !$this->canProcessFormSubmission()) {
277
            $this->formState = GeneralUtility::makeInstance(FormState::class);
278
        } else {
279
            try {
280
                $serializedFormState = $this->hashService->validateAndStripHmac($serializedFormStateWithHmac);
281
            } catch (InvalidHashException | InvalidArgumentForHashGenerationException $e) {
282
                throw new BadRequestException('The HMAC of the form state could not be validated.', 1581862823);
283
            }
284
            $this->formState = unserialize(base64_decode($serializedFormState));
285
        }
286
    }
287
288
    protected function triggerAfterFormStateInitialized(): void
289
    {
290
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['afterFormStateInitialized'] ?? [] as $className) {
291
            $hookObj = GeneralUtility::makeInstance($className);
292
            if ($hookObj instanceof AfterFormStateInitializedInterface) {
293
                $hookObj->afterFormStateInitialized($this);
294
            }
295
        }
296
    }
297
298
    /**
299
     * Initializes the current page data based on the current request, also modifiable by a hook
300
     */
301
    protected function initializeCurrentPageFromRequest()
302
    {
303
        // If there was no previous form submissions or if the current request
304
        // can't be processed (no POST request and/or cached) then display the first
305
        // form step
306
        if (!$this->formState->isFormSubmitted() || !$this->canProcessFormSubmission()) {
307
            $this->currentPage = $this->formDefinition->getPageByIndex(0);
308
309
            if (!$this->currentPage->isEnabled()) {
310
                throw new FormException('Disabling the first page is not allowed', 1527186844);
311
            }
312
313
            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['afterInitializeCurrentPage'] ?? [] as $className) {
314
                $hookObj = GeneralUtility::makeInstance($className);
315
                if (method_exists($hookObj, 'afterInitializeCurrentPage')) {
316
                    $this->currentPage = $hookObj->afterInitializeCurrentPage(
317
                        $this,
318
                        $this->currentPage,
319
                        null,
320
                        $this->request->getArguments()
321
                    );
322
                }
323
            }
324
            return;
325
        }
326
327
        $this->lastDisplayedPage = $this->formDefinition->getPageByIndex($this->formState->getLastDisplayedPageIndex());
328
        $currentPageIndex = (int)$this->request->getInternalArgument('__currentPage');
329
330
        if ($this->userWentBackToPreviousStep()) {
331
            if ($currentPageIndex < $this->lastDisplayedPage->getIndex()) {
332
                $currentPageIndex = $this->lastDisplayedPage->getIndex();
333
            }
334
        } else {
335
            if ($currentPageIndex > $this->lastDisplayedPage->getIndex() + 1) {
336
                $currentPageIndex = $this->lastDisplayedPage->getIndex() + 1;
337
            }
338
        }
339
340
        if ($currentPageIndex >= count($this->formDefinition->getPages())) {
341
            // Last Page
342
            $this->currentPage = null;
343
        } else {
344
            $this->currentPage = $this->formDefinition->getPageByIndex($currentPageIndex);
345
346
            if (!$this->currentPage->isEnabled()) {
347
                if ($currentPageIndex === 0) {
348
                    throw new FormException('Disabling the first page is not allowed', 1527186845);
349
                }
350
351
                if ($this->userWentBackToPreviousStep()) {
352
                    $this->currentPage = $this->getPreviousEnabledPage();
353
                } else {
354
                    $this->currentPage = $this->getNextEnabledPage();
355
                }
356
            }
357
        }
358
359
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['afterInitializeCurrentPage'] ?? [] as $className) {
360
            $hookObj = GeneralUtility::makeInstance($className);
361
            if (method_exists($hookObj, 'afterInitializeCurrentPage')) {
362
                $this->currentPage = $hookObj->afterInitializeCurrentPage(
363
                    $this,
364
                    $this->currentPage,
365
                    $this->lastDisplayedPage,
366
                    $this->request->getArguments()
367
                );
368
            }
369
        }
370
    }
371
372
    /**
373
     * Checks if the honey pot is active, and adds a validator if so.
374
     */
375
    protected function initializeHoneypotFromRequest()
376
    {
377
        $renderingOptions = $this->formDefinition->getRenderingOptions();
378
        if (!isset($renderingOptions['honeypot']['enable'])
379
            || $renderingOptions['honeypot']['enable'] === false
380
            || (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface
381
                && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isBackend())
382
        ) {
383
            return;
384
        }
385
386
        ArrayUtility::assertAllArrayKeysAreValid($renderingOptions['honeypot'], ['enable', 'formElementToUse']);
387
388
        if (!$this->isFirstRequest()) {
389
            $elementsCount = count($this->lastDisplayedPage->getElements());
390
            if ($elementsCount === 0) {
391
                return;
392
            }
393
394
            $honeypotNameFromSession = $this->getHoneypotNameFromSession($this->lastDisplayedPage);
395
            if ($honeypotNameFromSession) {
396
                $honeypotElement = $this->lastDisplayedPage->createElement($honeypotNameFromSession, $renderingOptions['honeypot']['formElementToUse']);
397
                $validator = $this->objectManager->get(EmptyValidator::class);
398
                $honeypotElement->addValidator($validator);
399
            }
400
        }
401
    }
402
403
    /**
404
     * Renders a hidden field if the honey pot is active.
405
     */
406
    protected function renderHoneypot()
407
    {
408
        $renderingOptions = $this->formDefinition->getRenderingOptions();
409
        if (!isset($renderingOptions['honeypot']['enable'])
410
            || $this->currentPage === null
411
            || $renderingOptions['honeypot']['enable'] === false
412
            || (($GLOBALS['TYPO3_REQUEST'] ?? null) instanceof ServerRequestInterface
413
                && ApplicationType::fromRequest($GLOBALS['TYPO3_REQUEST'])->isBackend())
414
        ) {
415
            return;
416
        }
417
418
        ArrayUtility::assertAllArrayKeysAreValid($renderingOptions['honeypot'], ['enable', 'formElementToUse']);
419
420
        if (!$this->isAfterLastPage()) {
421
            $elementsCount = count($this->currentPage->getElements());
422
            if ($elementsCount === 0) {
423
                return;
424
            }
425
426
            if (!$this->isFirstRequest()) {
427
                $honeypotNameFromSession = $this->getHoneypotNameFromSession($this->lastDisplayedPage);
428
                if ($honeypotNameFromSession) {
429
                    $honeypotElement = $this->formDefinition->getElementByIdentifier($honeypotNameFromSession);
430
                    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...
431
                        $this->lastDisplayedPage->removeElement($honeypotElement);
432
                    }
433
                }
434
            }
435
436
            $elementsCount = count($this->currentPage->getElements());
437
            $randomElementNumber = random_int(0, $elementsCount - 1);
438
            $honeypotName = substr(str_shuffle('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'), 0, random_int(5, 26));
439
440
            $referenceElement = $this->currentPage->getElements()[$randomElementNumber];
441
            $honeypotElement = $this->currentPage->createElement($honeypotName, $renderingOptions['honeypot']['formElementToUse']);
442
            $validator = $this->objectManager->get(EmptyValidator::class);
443
444
            $honeypotElement->addValidator($validator);
445
            if (random_int(0, 1) === 1) {
446
                $this->currentPage->moveElementAfter($honeypotElement, $referenceElement);
447
            } else {
448
                $this->currentPage->moveElementBefore($honeypotElement, $referenceElement);
449
            }
450
            $this->setHoneypotNameInSession($this->currentPage, $honeypotName);
451
        }
452
    }
453
454
    /**
455
     * @param Page $page
456
     * @return string|null
457
     */
458
    protected function getHoneypotNameFromSession(Page $page)
459
    {
460
        if ($this->isFrontendUserAuthenticated()) {
461
            $honeypotNameFromSession = $this->getFrontendUser()->getKey(
462
                'user',
463
                self::HONEYPOT_NAME_SESSION_IDENTIFIER . $this->getIdentifier() . $page->getIdentifier()
464
            );
465
        } else {
466
            $honeypotNameFromSession = $this->getFrontendUser()->getKey(
467
                'ses',
468
                self::HONEYPOT_NAME_SESSION_IDENTIFIER . $this->getIdentifier() . $page->getIdentifier()
469
            );
470
        }
471
        return $honeypotNameFromSession;
472
    }
473
474
    /**
475
     * @param Page $page
476
     * @param string $honeypotName
477
     */
478
    protected function setHoneypotNameInSession(Page $page, string $honeypotName)
479
    {
480
        if ($this->isFrontendUserAuthenticated()) {
481
            $this->getFrontendUser()->setKey(
482
                'user',
483
                self::HONEYPOT_NAME_SESSION_IDENTIFIER . $this->getIdentifier() . $page->getIdentifier(),
484
                $honeypotName
485
            );
486
        } else {
487
            $this->getFrontendUser()->setKey(
488
                'ses',
489
                self::HONEYPOT_NAME_SESSION_IDENTIFIER . $this->getIdentifier() . $page->getIdentifier(),
490
                $honeypotName
491
            );
492
        }
493
    }
494
495
    /**
496
     * Necessary to know if honeypot information should be stored in the user session info, or in the anonymous session
497
     *
498
     * @return bool true when a frontend user is logged, otherwise false
499
     */
500
    protected function isFrontendUserAuthenticated(): bool
501
    {
502
        return (bool)GeneralUtility::makeInstance(Context::class)
503
            ->getPropertyFromAspect('frontend.user', 'isLoggedIn', false);
504
    }
505
506
    protected function processVariants()
507
    {
508
        $conditionResolver = $this->getConditionResolver();
509
510
        $renderables = array_merge([$this->formDefinition], $this->formDefinition->getRenderablesRecursively());
511
        foreach ($renderables as $renderable) {
512
            if ($renderable instanceof VariableRenderableInterface) {
513
                $variants = $renderable->getVariants();
514
                foreach ($variants as $variant) {
515
                    if ($variant->conditionMatches($conditionResolver)) {
516
                        $variant->apply();
517
                    }
518
                }
519
            }
520
        }
521
    }
522
523
    /**
524
     * Returns TRUE if the last page of the form has been submitted, otherwise FALSE
525
     *
526
     * @return bool
527
     */
528
    protected function isAfterLastPage(): bool
529
    {
530
        return $this->currentPage === null;
531
    }
532
533
    /**
534
     * Returns TRUE if no previous page is stored in the FormState, otherwise FALSE
535
     *
536
     * @return bool
537
     */
538
    protected function isFirstRequest(): bool
539
    {
540
        return $this->lastDisplayedPage === null;
541
    }
542
543
    /**
544
     * @return bool
545
     */
546
    protected function isPostRequest(): bool
547
    {
548
        return $this->getRequest()->getMethod() === 'POST';
549
    }
550
551
    /**
552
     * Determine whether the surrounding content object is cached.
553
     * If no surrounding content object can be found (which would be strange)
554
     * we assume a cached request for safety which means that an empty form
555
     * will be rendered.
556
     *
557
     * @todo: this should be checked against https://forge.typo3.org/issues/91625 as this was fixed differently for UriBuilder
558
     * @return bool
559
     */
560
    protected function isRenderedCached(): bool
561
    {
562
        $contentObject = $this->configurationManager->getContentObject();
563
        return $contentObject === null
564
            ? true
565
            // @todo this does not work when rendering a cached `FLUIDTEMPLATE` (not nested in `COA_INT`)
566
            : $contentObject->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER;
567
    }
568
569
    /**
570
     * Runs through all validations
571
     */
572
    protected function processSubmittedFormValues()
573
    {
574
        $result = $this->mapAndValidatePage($this->lastDisplayedPage);
575
        if ($result->hasErrors() && !$this->userWentBackToPreviousStep()) {
576
            $this->currentPage = $this->lastDisplayedPage;
577
            $this->request->setOriginalRequestMappingResults($result);
578
        }
579
    }
580
581
    /**
582
     * returns TRUE if the user went back to any previous step in the form.
583
     *
584
     * @return bool
585
     */
586
    protected function userWentBackToPreviousStep(): bool
587
    {
588
        return !$this->isAfterLastPage() && !$this->isFirstRequest() && $this->currentPage->getIndex() < $this->lastDisplayedPage->getIndex();
589
    }
590
591
    /**
592
     * @param Page $page
593
     * @return Result
594
     * @throws PropertyMappingException
595
     */
596
    protected function mapAndValidatePage(Page $page): Result
597
    {
598
        $result = $this->objectManager->get(Result::class);
599
        $requestArguments = $this->request->getArguments();
600
601
        $propertyPathsForWhichPropertyMappingShouldHappen = [];
602
        $registerPropertyPaths = function ($propertyPath) use (&$propertyPathsForWhichPropertyMappingShouldHappen) {
603
            $propertyPathParts = explode('.', $propertyPath);
604
            $accumulatedPropertyPathParts = [];
605
            foreach ($propertyPathParts as $propertyPathPart) {
606
                $accumulatedPropertyPathParts[] = $propertyPathPart;
607
                $temporaryPropertyPath = implode('.', $accumulatedPropertyPathParts);
608
                $propertyPathsForWhichPropertyMappingShouldHappen[$temporaryPropertyPath] = $temporaryPropertyPath;
609
            }
610
        };
611
612
        $value = null;
613
614
        foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['afterSubmit'] ?? [] as $className) {
615
            $hookObj = GeneralUtility::makeInstance($className);
616
            if (method_exists($hookObj, 'afterSubmit')) {
617
                $value = $hookObj->afterSubmit(
618
                    $this,
619
                    $page,
620
                    $value,
621
                    $requestArguments
622
                );
623
            }
624
        }
625
626
        foreach ($page->getElementsRecursively() as $element) {
627
            if (!$element->isEnabled()) {
628
                continue;
629
            }
630
631
            try {
632
                $value = ArrayUtility::getValueByPath($requestArguments, $element->getIdentifier(), '.');
633
            } catch (MissingArrayPathException $exception) {
634
                $value = null;
635
            }
636
637
            foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/form']['afterSubmit'] ?? [] as $className) {
638
                $hookObj = GeneralUtility::makeInstance($className);
639
                if (method_exists($hookObj, 'afterSubmit')) {
640
                    $value = $hookObj->afterSubmit(
641
                        $this,
642
                        $element,
643
                        $value,
644
                        $requestArguments
645
                    );
646
                }
647
            }
648
649
            $this->formState->setFormValue($element->getIdentifier(), $value);
650
            $registerPropertyPaths($element->getIdentifier());
651
        }
652
653
        // The more parts the path has, the more early it is processed
654
        usort($propertyPathsForWhichPropertyMappingShouldHappen, function ($a, $b) {
655
            return substr_count($b, '.') - substr_count($a, '.');
656
        });
657
658
        $processingRules = $this->formDefinition->getProcessingRules();
659
660
        foreach ($propertyPathsForWhichPropertyMappingShouldHappen as $propertyPath) {
661
            if (isset($processingRules[$propertyPath])) {
662
                $processingRule = $processingRules[$propertyPath];
663
                $value = $this->formState->getFormValue($propertyPath);
664
                try {
665
                    $value = $processingRule->process($value);
666
                } catch (PropertyException $exception) {
667
                    throw new PropertyMappingException(
668
                        'Failed to process FormValue at "' . $propertyPath . '" from "' . gettype($value) . '" to "' . $processingRule->getDataType() . '"',
669
                        1480024933,
670
                        $exception
671
                    );
672
                }
673
                $result->forProperty($this->getIdentifier() . '.' . $propertyPath)->merge($processingRule->getProcessingMessages());
674
                $this->formState->setFormValue($propertyPath, $value);
675
            }
676
        }
677
678
        return $result;
679
    }
680
681
    /**
682
     * Override the current page taken from the request, rendering the page with index $pageIndex instead.
683
     *
684
     * This is typically not needed in production code, but it is very helpful when displaying
685
     * some kind of "preview" of the form (e.g. form editor).
686
     *
687
     * @param int $pageIndex
688
     */
689
    public function overrideCurrentPage(int $pageIndex)
690
    {
691
        $this->currentPage = $this->formDefinition->getPageByIndex($pageIndex);
692
    }
693
694
    /**
695
     * Render this form.
696
     *
697
     * @return string|null rendered form
698
     * @throws RenderingException
699
     */
700
    public function render()
701
    {
702
        if ($this->isAfterLastPage()) {
703
            return $this->invokeFinishers();
704
        }
705
        $this->processVariants();
706
707
        $this->formState->setLastDisplayedPageIndex($this->currentPage->getIndex());
708
709
        if ($this->formDefinition->getRendererClassName() === '') {
710
            throw new RenderingException(sprintf('The form definition "%s" does not have a rendererClassName set.', $this->formDefinition->getIdentifier()), 1326095912);
711
        }
712
        $rendererClassName = $this->formDefinition->getRendererClassName();
713
        $renderer = $this->objectManager->get($rendererClassName);
714
        if (!($renderer instanceof RendererInterface)) {
715
            throw new RenderingException(sprintf('The renderer "%s" des not implement RendererInterface', $rendererClassName), 1326096024);
716
        }
717
718
        $controllerContext = $this->getControllerContext();
719
720
        $renderer->setControllerContext($controllerContext);
721
        $renderer->setFormRuntime($this);
722
        return $renderer->render();
723
    }
724
725
    /**
726
     * Executes all finishers of this form
727
     *
728
     * @return string
729
     */
730
    protected function invokeFinishers(): string
731
    {
732
        $finisherContext = $this->objectManager->get(
733
            FinisherContext::class,
734
            $this,
735
            $this->getControllerContext(),
736
            $this->request
737
        );
738
739
        $output = '';
740
        $this->response->getBody()->rewind();
741
        $originalContent = $this->response->getBody()->getContents();
742
        $this->response->getBody()->write('');
743
        foreach ($this->formDefinition->getFinishers() as $finisher) {
744
            $this->currentFinisher = $finisher;
745
            $this->processVariants();
746
747
            $finisherOutput = $finisher->execute($finisherContext);
748
            if (is_string($finisherOutput) && !empty($finisherOutput)) {
749
                $output .= $finisherOutput;
750
            } else {
751
                $this->response->getBody()->rewind();
752
                $output .= $this->response->getBody()->getContents();
753
                $this->response->getBody()->write('');
754
            }
755
756
            if ($finisherContext->isCancelled()) {
757
                break;
758
            }
759
        }
760
        $this->response->getBody()->rewind();
761
        $this->response->getBody()->write($originalContent);
762
763
        return $output;
764
    }
765
766
    /**
767
     * @return string The identifier of underlying form
768
     */
769
    public function getIdentifier(): string
770
    {
771
        return $this->formDefinition->getIdentifier();
772
    }
773
774
    /**
775
     * Get the request this object is bound to.
776
     *
777
     * This is mostly relevant inside Finishers, where you f.e. want to redirect
778
     * the user to another page.
779
     *
780
     * @return Request the request this object is bound to
781
     */
782
    public function getRequest(): Request
783
    {
784
        return $this->request;
785
    }
786
787
    /**
788
     * Get the response this object is bound to.
789
     *
790
     * This is mostly relevant inside Finishers, where you f.e. want to set response
791
     * headers or output content.
792
     *
793
     * @return ResponseInterface the response this object is bound to
794
     */
795
    public function getResponse(): ResponseInterface
796
    {
797
        return $this->response;
798
    }
799
800
    /**
801
     * Only process values if there is a post request and if the
802
     * surrounding content object is uncached.
803
     * Is this not the case, all possible submitted values will be discarded
804
     * and the first form step will be shown with an empty form state.
805
     *
806
     * @return bool
807
     * @internal
808
     */
809
    public function canProcessFormSubmission(): bool
810
    {
811
        return $this->isPostRequest() && !$this->isRenderedCached();
812
    }
813
814
    /**
815
     * @return FormSession|null
816
     * @internal
817
     */
818
    public function getFormSession(): ?FormSession
819
    {
820
        return $this->formSession;
821
    }
822
823
    /**
824
     * Returns the currently selected page
825
     *
826
     * @return Page|null
827
     */
828
    public function getCurrentPage(): ?Page
829
    {
830
        return $this->currentPage;
831
    }
832
833
    /**
834
     * Returns the previous page of the currently selected one or NULL if there is no previous page
835
     *
836
     * @return Page|null
837
     */
838
    public function getPreviousPage(): ?Page
839
    {
840
        $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

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