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