Passed
Push — master ( 5527b0...962042 )
by
unknown
13:01
created

FormRuntime::getTemplateName()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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

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