1<?php
2
3declare(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
18namespace TYPO3\CMS\Backend\Controller;
19
20use Psr\Http\Message\ResponseInterface;
21use Psr\Http\Message\ServerRequestInterface;
22use TYPO3\CMS\Backend\Configuration\SiteTcaConfiguration;
23use TYPO3\CMS\Backend\Form\FormDataCompiler;
24use TYPO3\CMS\Backend\Form\FormDataGroup\SiteConfigurationDataGroup;
25use TYPO3\CMS\Backend\Form\InlineStackProcessor;
26use TYPO3\CMS\Backend\Form\NodeFactory;
27use TYPO3\CMS\Core\Http\JsonResponse;
28use TYPO3\CMS\Core\Localization\Locales;
29use TYPO3\CMS\Core\Page\JavaScriptItems;
30use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
31use TYPO3\CMS\Core\Site\SiteFinder;
32use TYPO3\CMS\Core\Utility\ArrayUtility;
33use TYPO3\CMS\Core\Utility\GeneralUtility;
34use TYPO3\CMS\Core\Utility\MathUtility;
35
36/**
37 * Site configuration FormEngine controller class. Receives inline "edit" and "new"
38 * commands to expand / create site configuration inline records
39 * @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
40 */
41class SiteInlineAjaxController extends AbstractFormEngineAjaxController
42{
43    /**
44     * Default constructor
45     */
46    public function __construct()
47    {
48        // Bring site TCA into global scope.
49        // @todo: We might be able to get rid of that later
50        $GLOBALS['TCA'] = array_merge($GLOBALS['TCA'], GeneralUtility::makeInstance(SiteTcaConfiguration::class)->getTca());
51    }
52
53    /**
54     * Inline "create" new child of site configuration child records
55     *
56     * @param ServerRequestInterface $request
57     * @return ResponseInterface
58     * @throws \RuntimeException
59     */
60    public function newInlineChildAction(ServerRequestInterface $request): ResponseInterface
61    {
62        $ajaxArguments = $request->getParsedBody()['ajax'] ?? $request->getQueryParams()['ajax'];
63        $parentConfig = $this->extractSignedParentConfigFromRequest((string)$ajaxArguments['context']);
64        $domObjectId = $ajaxArguments[0];
65        $inlineFirstPid = $this->getInlineFirstPidFromDomObjectId($domObjectId);
66        $childChildUid = null;
67        if (isset($ajaxArguments[1]) && MathUtility::canBeInterpretedAsInteger($ajaxArguments[1])) {
68            $childChildUid = (int)$ajaxArguments[1];
69        }
70        // Parse the DOM identifier, add the levels to the structure stack
71        $inlineStackProcessor = GeneralUtility::makeInstance(InlineStackProcessor::class);
72        $inlineStackProcessor->initializeByParsingDomObjectIdString($domObjectId);
73        $inlineStackProcessor->injectAjaxConfiguration($parentConfig);
74        $inlineTopMostParent = $inlineStackProcessor->getStructureLevel(0);
75        // Parent, this table embeds the child table
76        $parent = $inlineStackProcessor->getStructureLevel(-1);
77        // Child, a record from this table should be rendered
78        $child = $inlineStackProcessor->getUnstableStructure();
79        if (MathUtility::canBeInterpretedAsInteger($child['uid'] ?? false)) {
80            // If uid comes in, it is the id of the record neighbor record "create after"
81            $childVanillaUid = -1 * abs((int)$child['uid']);
82        } else {
83            // Else inline first Pid is the storage pid of new inline records
84            $childVanillaUid = (int)$inlineFirstPid;
85        }
86        $childTableName = $parentConfig['foreign_table'];
87        $defaultDatabaseRow = [];
88
89        if ($childTableName === 'site_language') {
90            if ($childChildUid !== null) {
91                $language = $this->getLanguageById($childChildUid);
92                if ($language !== null) {
93                    $defaultDatabaseRow['languageId'] = $language->getLanguageId();
94                    $defaultDatabaseRow['locale'] = $language->getLocale();
95                    if ($language->getTitle() !== '') {
96                        $defaultDatabaseRow['title'] = $language->getTitle();
97                    }
98                    if ($language->getTypo3Language() !== '') {
99                        $locales = GeneralUtility::makeInstance(Locales::class);
100                        $allLanguages = $locales->getLanguages();
101                        if (isset($allLanguages[$language->getTypo3Language()])) {
102                            $defaultDatabaseRow['typo3Language'] = $language->getTypo3Language();
103                        }
104                    }
105                    if ($language->getTwoLetterIsoCode() !== '') {
106                        $defaultDatabaseRow['iso-639-1'] = $language->getTwoLetterIsoCode();
107                        if ($language->getBase()->getPath() !== '/') {
108                            $defaultDatabaseRow['base'] = '/' . $language->getTwoLetterIsoCode() . '/';
109                        }
110                    }
111                    if ($language->getNavigationTitle() !== '') {
112                        $defaultDatabaseRow['navigationTitle'] = $language->getNavigationTitle();
113                    }
114                    if ($language->getHreflang() !== '') {
115                        $defaultDatabaseRow['hreflang'] = $language->getHreflang();
116                    }
117                    if ($language->getDirection() !== '') {
118                        $defaultDatabaseRow['direction'] = $language->getDirection();
119                    }
120                    if (strpos($language->getFlagIdentifier(), 'flags-') === 0) {
121                        $flagIdentifier = str_replace('flags-', '', $language->getFlagIdentifier());
122                        $defaultDatabaseRow['flag'] = ($flagIdentifier === 'multiple') ? 'global' : $flagIdentifier;
123                    }
124                } elseif ($childChildUid !== 0) {
125                    // In case no language could be found for $childChildUid and
126                    // its value is not "0", which is a special case as the default
127                    // language is added automatically, throw a custom exception.
128                    throw new \RuntimeException('Referenced language not found', 1521783937);
129                }
130            } else {
131                // Set new childs' UID to PHP_INT_MAX, as this is the placeholder UID for
132                // new records, created with the "Create new" button. This is necessary
133                // as we use the "inline selector" mode which usually does not allow
134                // to create new records besides the ones, defined in the selector.
135                // The correct UID will then be calculated by the controller.
136                $childChildUid = PHP_INT_MAX;
137            }
138        }
139
140        $formDataGroup = GeneralUtility::makeInstance(SiteConfigurationDataGroup::class);
141        $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
142        $formDataCompilerInput = [
143            'command' => 'new',
144            'tableName' => $childTableName,
145            'vanillaUid' => $childVanillaUid,
146            'databaseRow' => $defaultDatabaseRow,
147            'isInlineChild' => true,
148            'inlineStructure' => $inlineStackProcessor->getStructure(),
149            'inlineFirstPid' => $inlineFirstPid,
150            'inlineParentUid' => $parent['uid'],
151            'inlineParentTableName' => $parent['table'],
152            'inlineParentFieldName' => $parent['field'],
153            'inlineParentConfig' => $parentConfig,
154            'inlineTopMostParentUid' => $inlineTopMostParent['uid'],
155            'inlineTopMostParentTableName' => $inlineTopMostParent['table'],
156            'inlineTopMostParentFieldName' => $inlineTopMostParent['field'],
157        ];
158        if ($childChildUid) {
159            $formDataCompilerInput['inlineChildChildUid'] = $childChildUid;
160        }
161        $childData = $formDataCompiler->compile($formDataCompilerInput);
162
163        if (($parentConfig['foreign_selector'] ?? false) && ($parentConfig['appearance']['useCombination'] ?? false)) {
164            throw new \RuntimeException('useCombination not implemented in sites module', 1522493094);
165        }
166
167        $childData['inlineParentUid'] = (int)$parent['uid'];
168        $childData['renderType'] = 'inlineRecordContainer';
169        $nodeFactory = GeneralUtility::makeInstance(NodeFactory::class);
170        $childResult = $nodeFactory->create($childData)->render();
171
172        $jsonArray = [
173            'data' => '',
174            'stylesheetFiles' => [],
175            'scriptItems' => GeneralUtility::makeInstance(JavaScriptItems::class),
176            'scriptCall' => [],
177            'compilerInput' => [
178                'uid' => $childData['databaseRow']['uid'],
179                'childChildUid' => $childChildUid,
180                'parentConfig' => $parentConfig,
181            ],
182        ];
183
184        $jsonArray = $this->mergeChildResultIntoJsonResult($jsonArray, $childResult);
185
186        return new JsonResponse($jsonArray);
187    }
188
189    /**
190     * Show the details of site configuration child records.
191     *
192     * @param ServerRequestInterface $request
193     * @return ResponseInterface
194     * @throws \RuntimeException
195     */
196    public function openInlineChildAction(ServerRequestInterface $request): ResponseInterface
197    {
198        $ajaxArguments = $request->getParsedBody()['ajax'] ?? $request->getQueryParams()['ajax'];
199
200        $domObjectId = $ajaxArguments[0];
201        $inlineFirstPid = $this->getInlineFirstPidFromDomObjectId($domObjectId);
202        $parentConfig = $this->extractSignedParentConfigFromRequest((string)$ajaxArguments['context']);
203
204        // Parse the DOM identifier, add the levels to the structure stack
205        $inlineStackProcessor = GeneralUtility::makeInstance(InlineStackProcessor::class);
206        $inlineStackProcessor->initializeByParsingDomObjectIdString($domObjectId);
207        $inlineStackProcessor->injectAjaxConfiguration($parentConfig);
208
209        // Parent, this table embeds the child table
210        $parent = $inlineStackProcessor->getStructureLevel(-1);
211        $parentFieldName = $parent['field'];
212
213        // Set flag in config so that only the fields are rendered
214        // @todo: Solve differently / rename / whatever
215        $parentConfig['renderFieldsOnly'] = true;
216
217        $parentData = [
218            'processedTca' => [
219                'columns' => [
220                    $parentFieldName => [
221                        'config' => $parentConfig,
222                    ],
223                ],
224            ],
225            'uid' => $parent['uid'],
226            'tableName' => $parent['table'],
227            'inlineFirstPid' => $inlineFirstPid,
228            // Hand over given original return url to compile stack. Needed if inline children compile links to
229            // another view (eg. edit metadata in a nested inline situation like news with inline content element image),
230            // so the back link is still the link from the original request. See issue #82525. This is additionally
231            // given down in TcaInline data provider to compiled children data.
232            'returnUrl' => $parentConfig['originalReturnUrl'],
233        ];
234
235        // Child, a record from this table should be rendered
236        $child = $inlineStackProcessor->getUnstableStructure();
237
238        $childData = $this->compileChild($parentData, $parentFieldName, (int)$child['uid'], $inlineStackProcessor->getStructure());
239
240        $childData['inlineParentUid'] = (int)$parent['uid'];
241        $childData['renderType'] = 'inlineRecordContainer';
242        $nodeFactory = GeneralUtility::makeInstance(NodeFactory::class);
243        $childResult = $nodeFactory->create($childData)->render();
244
245        $jsonArray = [
246            'data' => '',
247            'stylesheetFiles' => [],
248            'scriptItems' => GeneralUtility::makeInstance(JavaScriptItems::class),
249            'scriptCall' => [],
250        ];
251
252        $jsonArray = $this->mergeChildResultIntoJsonResult($jsonArray, $childResult);
253
254        return new JsonResponse($jsonArray);
255    }
256
257    /**
258     * Compile a full child record
259     *
260     * @param array $parentData Result array of parent
261     * @param string $parentFieldName Name of parent field
262     * @param int $childUid Uid of child to compile
263     * @param array $inlineStructure Current inline structure
264     * @return array Full result array
265     * @throws \RuntimeException
266     *
267     * @todo: This clones methods compileChild from TcaInline Provider. Find a better abstraction
268     * @todo: to also encapsulate the more complex scenarios with combination child and friends.
269     */
270    protected function compileChild(array $parentData, string $parentFieldName, int $childUid, array $inlineStructure): array
271    {
272        $parentConfig = $parentData['processedTca']['columns'][$parentFieldName]['config'];
273
274        $inlineStackProcessor = GeneralUtility::makeInstance(InlineStackProcessor::class);
275        $inlineStackProcessor->initializeByGivenStructure($inlineStructure);
276        $inlineTopMostParent = $inlineStackProcessor->getStructureLevel(0);
277
278        // @todo: do not use stack processor here ...
279        $child = $inlineStackProcessor->getUnstableStructure();
280        $childTableName = $child['table'];
281
282        $formDataGroup = GeneralUtility::makeInstance(SiteConfigurationDataGroup::class);
283        $formDataCompiler = GeneralUtility::makeInstance(FormDataCompiler::class, $formDataGroup);
284        $formDataCompilerInput = [
285            'command' => 'edit',
286            'tableName' => $childTableName,
287            'vanillaUid' => (int)$childUid,
288            'returnUrl' => $parentData['returnUrl'],
289            'isInlineChild' => true,
290            'inlineStructure' => $inlineStructure,
291            'inlineFirstPid' => $parentData['inlineFirstPid'],
292            'inlineParentConfig' => $parentConfig,
293            'isInlineAjaxOpeningContext' => true,
294
295            // values of the current parent element
296            // it is always a string either an id or new...
297            'inlineParentUid' => $parentData['uid'],
298            'inlineParentTableName' => $parentData['tableName'],
299            'inlineParentFieldName' => $parentFieldName,
300
301            // values of the top most parent element set on first level and not overridden on following levels
302            'inlineTopMostParentUid' => $inlineTopMostParent['uid'],
303            'inlineTopMostParentTableName' => $inlineTopMostParent['table'],
304            'inlineTopMostParentFieldName' => $inlineTopMostParent['field'],
305        ];
306        if (($parentConfig['foreign_selector'] ?? false) && ($parentConfig['appearance']['useCombination'] ?? false)) {
307            throw new \RuntimeException('useCombination not implemented in sites module', 1522493095);
308        }
309        return $formDataCompiler->compile($formDataCompilerInput);
310    }
311
312    /**
313     * Merge stuff from child array into json array.
314     * This method is needed since ajax handling methods currently need to put scriptCalls before and after child code.
315     *
316     * @param array $jsonResult Given json result
317     * @param array $childResult Given child result
318     * @return array Merged json array
319     */
320    protected function mergeChildResultIntoJsonResult(array $jsonResult, array $childResult): array
321    {
322        /** @var JavaScriptItems $scriptItems */
323        $scriptItems = $jsonResult['scriptItems'];
324
325        $jsonResult['data'] .= $childResult['html'];
326        $jsonResult['stylesheetFiles'] = [];
327        foreach ($childResult['stylesheetFiles'] as $stylesheetFile) {
328            $jsonResult['stylesheetFiles'][] = $this->getRelativePathToStylesheetFile($stylesheetFile);
329        }
330        if (!empty($childResult['inlineData'])) {
331            $jsonResult['inlineData'] = $childResult['inlineData'];
332        }
333        // @todo deprecate with TYPO3 v12.0
334        foreach ($childResult['additionalJavaScriptPost'] as $singleAdditionalJavaScriptPost) {
335            $jsonResult['scriptCall'][] = $singleAdditionalJavaScriptPost;
336        }
337        if (!empty($childResult['additionalInlineLanguageLabelFiles'])) {
338            $labels = [];
339            foreach ($childResult['additionalInlineLanguageLabelFiles'] as $additionalInlineLanguageLabelFile) {
340                ArrayUtility::mergeRecursiveWithOverrule(
341                    $labels,
342                    $this->getLabelsFromLocalizationFile($additionalInlineLanguageLabelFile)
343                );
344            }
345            $scriptItems->addGlobalAssignment(['TYPO3' => ['lang' => $labels]]);
346        }
347        $this->addRegisteredRequireJsModulesToJavaScriptItems($childResult, $scriptItems);
348        // @todo deprecate modules with arbitrary JavaScript callback function in TYPO3 v12.0
349        $jsonResult['requireJsModules'] = $this->createExecutableStringRepresentationOfRegisteredRequireJsModules($childResult, true);
350
351        return $jsonResult;
352    }
353
354    /**
355     * Inline ajax helper method.
356     *
357     * Validates the config that is transferred over the wire to provide the
358     * correct TCA config for the parent table
359     *
360     * @param string $contextString
361     * @throws \RuntimeException
362     * @return array
363     */
364    protected function extractSignedParentConfigFromRequest(string $contextString): array
365    {
366        if ($contextString === '') {
367            throw new \RuntimeException('Empty context string given', 1522771624);
368        }
369        $context = json_decode($contextString, true);
370        if (empty($context['config'])) {
371            throw new \RuntimeException('Empty context config section given', 1522771632);
372        }
373        $config = json_decode($context['config'], true);
374        // encode JSON again to ensure same `json_encode()` settings as used when generating original hash
375        // (side-note: JSON encoded literals differ for target scenarios, e.g. HTML attr, JS string, ...)
376        $encodedConfig = (string)json_encode($config);
377        if (!hash_equals(GeneralUtility::hmac($encodedConfig, 'InlineContext'), (string)$context['hmac'])) {
378            throw new \RuntimeException('Hash does not validate', 1522771640);
379        }
380        return $config;
381    }
382
383    /**
384     * Get inlineFirstPid from a given objectId string
385     *
386     * @param string $domObjectId The id attribute of an element
387     * @return int|null Pid or null
388     */
389    protected function getInlineFirstPidFromDomObjectId(string $domObjectId): ?int
390    {
391        // Substitute FlexForm addition and make parsing a bit easier
392        $domObjectId = str_replace('---', ':', $domObjectId);
393        // The starting pattern of an object identifier (e.g. "data-<firstPidValue>-<anything>)
394        $pattern = '/^data-(.+?)-(.+)$/';
395        if (preg_match($pattern, $domObjectId, $match)) {
396            return (int)$match[1];
397        }
398        return null;
399    }
400
401    /**
402     * Find a site language by id. This will return the first occurrence of a
403     * language, even if the same language is used in other site configurations.
404     *
405     * @param int $languageId
406     * @return SiteLanguage|null
407     */
408    protected function getLanguageById(int $languageId): ?SiteLanguage
409    {
410        foreach (GeneralUtility::makeInstance(SiteFinder::class)->getAllSites() as $site) {
411            foreach ($site->getAllLanguages() as $language) {
412                if ($languageId === $language->getLanguageId()) {
413                    return $language;
414                }
415            }
416        }
417
418        return null;
419    }
420}
421