getAttribute('moduleData'); $viewMode = SetupModuleViewMode::tryFrom($moduleData->get('viewMode') ?? '') ?? SetupModuleViewMode::TILES; $moduleData->set('viewMode', $viewMode->value); // forcing uncached sites will re-initialize `SiteFinder` // which is used later by FormEngine (implicit behavior) $allSites = $this->siteFinder->getAllSites(false); $pages = $this->getAllSitePages(); $unassignedSites = []; $duplicatedRootPages = []; foreach ($allSites as $identifier => $site) { $rootPageId = $site->getRootPageId(); if (isset($pages[$rootPageId]['siteConfiguration'])) { // rootPage is already used in a site configuration $duplicatedRootPages[$rootPageId][] = $pages[$rootPageId]['siteConfiguration']->getIdentifier(); $duplicatedRootPages[$rootPageId][] = $site->getIdentifier(); $duplicatedRootPages[$rootPageId] = array_unique($duplicatedRootPages[$rootPageId]); } if (isset($pages[$rootPageId])) { $pages[$rootPageId]['siteIdentifier'] = $identifier; $pages[$rootPageId]['siteConfiguration'] = $site; } else { $unassignedSites[] = $site; } } $rootPagesWithSiteConfiguration = []; $rootPagesWithoutSiteConfiguration = []; foreach ($pages as $page) { if (!isset($page['siteConfiguration'])) { $rootPagesWithoutSiteConfiguration[] = $page; } else { $rootPagesWithSiteConfiguration[] = $page; } } $view = $this->moduleTemplateFactory->create($request); $view->getDocHeaderComponent()->setShortcutContext( 'site_configuration', $this->getLanguageService()->translate('short_description', 'backend.modules.site_configuration') ); $this->addDocHeaderViewModeButton($view, $viewMode); $view->setTitle($this->getLanguageService()->translate('title', 'backend.modules.site_configuration')); $view->setLayout(ModuleLayout::NORMAL); $view->assignMultiple([ 'pages' => $pages, 'viewMode' => $viewMode, 'unassignedSites' => $unassignedSites, 'duplicatedRootPages' => $duplicatedRootPages, 'duplicatedEntryPoints' => $this->getDuplicatedEntryPoints($allSites, $pages), 'invalidSets' => $this->setRegistry->getInvalidSets(), 'rootPagesWithSiteConfiguration' => $rootPagesWithSiteConfiguration, 'rootPagesWithoutSiteConfiguration' => $rootPagesWithoutSiteConfiguration, ]); return $view->renderResponse('SiteConfiguration/Overview'); } /** * This lists all information about a site: * - URLs * - Languages + Translation Strategy */ public function detailAction(ServerRequestInterface $request): ResponseInterface { $siteIdentifier = $request->getQueryParams()['site'] ?? null; if (empty($siteIdentifier)) { throw new \RuntimeException('Site identifier to show details must be set', 1763919655); } $site = $this->siteFinder->getSiteByIdentifier($siteIdentifier); $pageRecord = BackendUtility::getRecord('pages', $site->getRootPageId()) ?? []; $settings = $this->siteSettingsService->getUncachedSettings($site); $setSettings = $this->siteSettingsService->getSetSettings($site); $categoryEnhancer = function (Category $category) use (&$categoryEnhancer, $settings, $setSettings): Category { return new Category(...[ ...get_object_vars($category), 'label' => $this->getLanguageService()->sL($category->label), 'description' => $category->description !== null ? $this->getLanguageService()->sL($category->description) : $category->description, 'categories' => array_map($categoryEnhancer, $category->categories), 'settings' => array_map( fn(SettingDefinition $definition): EditableSetting => new EditableSetting( definition: $this->resolveSettingLabels($definition), value: $settings->get($definition->key), systemDefault: $setSettings->get($definition->key), typeImplementation: $this->settingsTypeRegistry->get($definition->type)->getJavaScriptModule(), ), $category->settings ), ]); }; $categories = array_map($categoryEnhancer, $this->categoryRegistry->getCategories(...$site->getSets())); $view = $this->moduleTemplateFactory->create($request); $this->configureDetailViewDocHeader($view, $siteIdentifier, $request); $view->getDocHeaderComponent()->setPageBreadcrumb($pageRecord); $view->setTitle( $this->getLanguageService()->translate('title', 'backend.modules.site_settings') ); $view->setLayout(ModuleLayout::NORMAL); $view->assignMultiple([ 'site' => $site, 'page' => $pageRecord, 'categories' => $categories, 'localSettings' => $this->siteSettingsService->getLocalSettings($site), ]); // @todo: Find CSP information (if active etc) return $view->renderResponse('SiteConfiguration/Detail'); } /** * Shows a form to create a new site configuration, or edit an existing one. * * @throws \RuntimeException */ public function editAction(ServerRequestInterface $request): ResponseInterface { // forcing uncached sites will re-initialize `SiteFinder` // which is used later by FormEngine (implicit behavior) $allSites = $this->siteFinder->getAllSites(false); $fullTca = array_merge($GLOBALS['TCA'], $this->siteTcaConfiguration->getTca()); $pageUid = (int)($request->getQueryParams()['pageUid'] ?? 0); $siteIdentifier = $request->getQueryParams()['site'] ?? null; if (empty($siteIdentifier) && empty($pageUid)) { throw new \RuntimeException('Either site identifier to edit a config or page uid to add new config must be set', 1521561148); } $isNewConfig = empty($siteIdentifier); $defaultValues = []; if ($isNewConfig) { $defaultValues['site']['rootPageId'] = $pageUid; $pageRecord = BackendUtility::getRecord('pages', $pageUid) ?? []; } else { $site = $this->siteFinder->getSiteByIdentifier($siteIdentifier); $pageRecord = BackendUtility::getRecord('pages', $site->getRootPageId()) ?? []; } if (!$isNewConfig && !isset($allSites[$siteIdentifier])) { throw new \RuntimeException('Existing config for site ' . $siteIdentifier . ' not found', 1521561226); } $returnUrl = $this->resolveReturnUrl($request); $formDataCompilerInput = [ 'request' => $request, 'tableName' => 'site', 'vanillaUid' => $isNewConfig ? $pageUid : $allSites[$siteIdentifier]->getRootPageId(), 'command' => $isNewConfig ? 'new' : 'edit', 'returnUrl' => $returnUrl, 'customData' => [ 'siteIdentifier' => $isNewConfig ? '' : $siteIdentifier, ], 'defaultValues' => $defaultValues, 'tcaSchemata' => $this->tcaSchemaBuilder->buildFromStructure($fullTca), 'fullTca' => $fullTca, ]; $formData = $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(SiteConfigurationDataGroup::class)); $formData['renderType'] = 'formWrapContainer'; $formResult = $this->nodeFactory->create($formData)->render(); $languageService = $this->getLanguageService(); $documentTitle = $this->resolveDocumentTitle($languageService, $isNewConfig, $siteIdentifier); $formResult['html'] = '

' . htmlspecialchars($documentTitle) . '

' . $formResult['html']; $formResult = $this->formResultFactory->create($formResult); $this->formResultHandler->addAssets($formResult); $view = $this->moduleTemplateFactory->create($request); $view->assignMultiple([ // Always add rootPageId as additional field to have a reference for new records 'rootPageId' => $isNewConfig ? $pageUid : $allSites[$siteIdentifier]->getRootPageId(), 'returnUrl' => $returnUrl, 'formEngineHtml' => $formResult->html, ]); $this->configureEditViewDocHeader($view, $siteIdentifier, $documentTitle); $view->getDocHeaderComponent()->setPageBreadcrumb($pageRecord); $view->setTitle($documentTitle); $view->setLayout(ModuleLayout::NORMAL); return $view->renderResponse('SiteConfiguration/Edit'); } /** * Save incoming data from editAction and redirect to overview or edit * * @throws \RuntimeException */ public function saveAction(ServerRequestInterface $request): ResponseInterface { // loading uncached site configurations without settings.yaml /** @var array $mappingRootPageToSite */ $mappingRootPageToSite = []; $allSites = $this->siteConfiguration->resolveAllExistingSitesRaw(); foreach ($allSites as $site) { $mappingRootPageToSite[$site->getRootPageId()] = $site; } $parsedBody = $request->getParsedBody(); $returnUrl = $this->resolveReturnUrl($request); if (isset($parsedBody['closeDoc']) && (int)$parsedBody['closeDoc'] === 1) { // Closing means no save, just redirect to overview return new RedirectResponse($returnUrl); } $isSave = $parsedBody['_savedok'] ?? false; $isSaveClose = $parsedBody['_saveandclosedok'] ?? false; if (!$isSave && !$isSaveClose) { throw new \RuntimeException('Either save or save and close', 1520370364); } if (!isset($parsedBody['data']['site']) || !is_array($parsedBody['data']['site'])) { throw new \RuntimeException('No site data or site identifier given', 1521030950); } $data = $parsedBody['data']; // This can be NEW123 for new records $unprocessedPageId = key($data['site']); $isRootPageIdPlaceholder = $this->envPlaceholderProcessor->canProcess((string)$unprocessedPageId); $pageId = $isRootPageIdPlaceholder ? (int)$this->envPlaceholderProcessor->process($unprocessedPageId) : (int)$unprocessedPageId; $sysSiteRow = current($data['site']); $siteIdentifier = $sysSiteRow['identifier'] ?? ''; $isNewConfiguration = false; $currentIdentifier = ''; if (isset($mappingRootPageToSite[$pageId])) { $currentSite = $mappingRootPageToSite[$pageId]; $currentSiteConfiguration = $currentSite->getConfiguration(); $currentIdentifier = $currentSite->getIdentifier(); } else { $currentSiteConfiguration = []; $isNewConfiguration = true; $pageId = (int)$parsedBody['rootPageId']; if ($pageId <= 0) { // Early validation of rootPageId - it must always be given and greater than 0 throw new \RuntimeException('No root page id found', 1521719709); } } $siteTca = $this->siteTcaConfiguration->getTca(); // Validate site identifier and do not store or further process it $siteIdentifier = $this->validateAndProcessIdentifier($isNewConfiguration, $siteIdentifier, $pageId, $allSites, $mappingRootPageToSite, $siteTca); unset($sysSiteRow['identifier']); try { $newSysSiteData = []; // Hard set rootPageId: This is TCA readOnly and not transmitted by FormEngine, but is also the "uid" of the site record $newSysSiteData['rootPageId'] = $isRootPageIdPlaceholder ? $unprocessedPageId : $pageId; foreach ($sysSiteRow as $fieldName => $fieldValue) { $type = $siteTca['site']['columns'][$fieldName]['config']['type']; $renderType = $siteTca['site']['columns'][$fieldName]['config']['renderType'] ?? ''; switch ($type) { case 'input': case 'number': case 'email': case 'link': case 'datetime': case 'color': case 'text': $fieldValue = $this->validateAndProcessValue('site', $fieldName, $fieldValue, $siteTca); $newSysSiteData[$fieldName] = $fieldValue; break; case 'inline': $newSysSiteData[$fieldName] = []; $childRowIds = GeneralUtility::trimExplode(',', $fieldValue, true); if (!isset($siteTca['site']['columns'][$fieldName]['config']['foreign_table'])) { throw new \RuntimeException('No foreign_table found for inline type', 1521555037); } $foreignTable = $siteTca['site']['columns'][$fieldName]['config']['foreign_table']; foreach ($childRowIds as $childRowId) { $childRowData = []; if (!isset($data[$foreignTable][$childRowId])) { if (!empty($currentSiteConfiguration[$fieldName][$childRowId])) { // A collapsed inline record: Fetch data from existing config $newSysSiteData[$fieldName][] = $currentSiteConfiguration[$fieldName][$childRowId]; continue; } throw new \RuntimeException('No data found for table ' . $foreignTable . ' with id ' . $childRowId, 1521555177); } $childRow = $data[$foreignTable][$childRowId]; foreach ($childRow as $childFieldName => $childFieldValue) { if ($childFieldName === 'pid') { // pid is added by inline by default, but not relevant for yml storage continue; } $type = $siteTca[$foreignTable]['columns'][$childFieldName]['config']['type']; switch ($type) { case 'input': case 'number': case 'email': case 'link': case 'datetime': case 'color': case 'select': case 'text': $childRowData[$childFieldName] = $childFieldValue; break; case 'check': $childRowData[$childFieldName] = (bool)$childFieldValue; break; default: throw new \RuntimeException('TCA type ' . $type . ' not implemented in site handling', 1521555340); } } $newSysSiteData[$fieldName][] = $childRowData; } break; case 'siteLanguage': if (!isset($siteTca['site_language'])) { throw new \RuntimeException('Required foreign table site_language does not exist', 1624286811); } if (!isset($siteTca['site_language']['columns']['languageId']) || ($siteTca['site_language']['columns']['languageId']['config']['type'] ?? '') !== 'select' ) { throw new \RuntimeException( 'Required foreign field languageId does not exist or is not of type select', 1624286812 ); } $newSysSiteData[$fieldName] = []; $lastLanguageId = $this->getLastLanguageId(); foreach (GeneralUtility::trimExplode(',', $fieldValue, true) as $childRowId) { if (!isset($data['site_language'][$childRowId])) { if (!empty($currentSiteConfiguration[$fieldName][$childRowId])) { $newSysSiteData[$fieldName][] = $currentSiteConfiguration[$fieldName][$childRowId]; continue; } throw new \RuntimeException('No data found for table site_language with id ' . $childRowId, 1624286813); } $childRowData = []; foreach ($data['site_language'][$childRowId] ?? [] as $childFieldName => $childFieldValue) { if ($childFieldName === 'pid') { // pid is added by default, but not relevant for yml storage continue; } if ($childFieldName === 'languageId' && (int)$childFieldValue === PHP_INT_MAX && str_starts_with($childRowId, 'NEW') ) { // In case we deal with a new site language, whose "languageID" field is // set to the PHP_INT_MAX placeholder, the next available language ID has // to be used (auto-increment). $childRowData[$childFieldName] = ++$lastLanguageId; continue; } $type = $siteTca['site_language']['columns'][$childFieldName]['config']['type']; switch ($type) { case 'input': case 'number': case 'email': case 'link': case 'datetime': case 'color': case 'select': case 'text': $childRowData[$childFieldName] = $childFieldValue; break; case 'check': $childRowData[$childFieldName] = (bool)$childFieldValue; break; default: throw new \RuntimeException('TCA type ' . $type . ' not implemented in site handling', 1624286814); } } $newSysSiteData[$fieldName][] = $childRowData; } break; case 'select': if ($renderType === 'selectMultipleSideBySide') { $fieldValues = is_array($fieldValue) ? $fieldValue : GeneralUtility::trimExplode(',', $fieldValue, true); $newSysSiteData[$fieldName] = $fieldValues; } else { if (MathUtility::canBeInterpretedAsInteger($fieldValue)) { $fieldValue = (int)$fieldValue; } elseif (is_array($fieldValue)) { $fieldValue = implode(',', $fieldValue); } $newSysSiteData[$fieldName] = $fieldValue; } break; case 'check': $newSysSiteData[$fieldName] = (bool)$fieldValue; break; default: throw new \RuntimeException('TCA type "' . $type . '" is not implemented in site handling', 1521032781); } } $newSiteConfiguration = $this->validateFullStructure( $this->getMergeSiteData($currentSiteConfiguration, $newSysSiteData), $isNewConfiguration ); // Persist the configuration try { if (!$isNewConfiguration && $currentIdentifier !== $siteIdentifier) { $this->siteWriter->rename($currentIdentifier, $siteIdentifier); $this->getBackendUser()->writelog(Type::SITE, SiteAction::RENAME, SystemLogErrorClassification::MESSAGE, null, 'Site configuration \'%s\' was renamed to \'%s\'.', [$currentIdentifier, $siteIdentifier], 'site'); } $this->siteWriter->write($siteIdentifier, $newSiteConfiguration, true); if ($isNewConfiguration) { $this->getBackendUser()->writelog(Type::SITE, SiteAction::CREATE, SystemLogErrorClassification::MESSAGE, null, 'Site configuration \'%s\' was created.', [$siteIdentifier], 'site'); } else { $this->getBackendUser()->writelog(Type::SITE, SiteAction::UPDATE, SystemLogErrorClassification::MESSAGE, null, 'Site configuration \'%s\' was updated.', [$siteIdentifier], 'site'); } } catch (SiteConfigurationWriteException $e) { $flashMessage = new FlashMessage($e->getMessage(), '', ContextualFeedbackSeverity::WARNING, true); $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(); $defaultFlashMessageQueue->enqueue($flashMessage); } } catch (SiteValidationErrorException $e) { // Do not store new config if a validation error is thrown, but redirect only to show a generated flash message } $saveRoute = $this->uriBuilder->buildUriFromRoute('site_configuration.edit', [ 'site' => $siteIdentifier, 'returnUrl' => $returnUrl, ]); if ($isSaveClose) { return new RedirectResponse($returnUrl); } return new RedirectResponse($saveRoute); } /** * Validation and processing of site identifier * * @param bool $isNew If true, we're dealing with a new record * @param string $identifier Given identifier to validate and process * @param int $rootPageId Page uid this identifier is bound to * @param array $allSites All sites loaded without `settings.yaml`. * @param array $mappingRootPageToSite Identifier site mapping as lookup. Not loaded `settings.yaml`. * @param array $siteTca TCA for site * @return mixed Verified / modified value */ protected function validateAndProcessIdentifier(bool $isNew, string $identifier, int $rootPageId, array $allSites, array $mappingRootPageToSite, array $siteTca) { $languageService = $this->getLanguageService(); // Normal "eval" processing of field first $identifier = $this->validateAndProcessValue('site', 'identifier', $identifier, $siteTca); if ($isNew) { // Verify no other site with this identifier exists. If so, find a new unique name as // identifier and show a flash message the identifier has been adapted if (($allSites[$identifier] ?? null) instanceof Site) { // Force this identifier to be unique $originalIdentifier = $identifier; $identifier = StringUtility::getUniqueId($identifier . '-'); $message = sprintf( $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:validation.identifierRenamed.message'), $originalIdentifier, $identifier ); $messageTitle = $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:validation.identifierRenamed.title'); $flashMessage = new FlashMessage($message, $messageTitle, ContextualFeedbackSeverity::WARNING, true); $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(); $defaultFlashMessageQueue->enqueue($flashMessage); } } else { // If this is an existing config, the site for this identifier must have the same rootPageId, otherwise // a user tried to rename a site identifier to a different site that already exists. If so, we do not rename // the site and show a flash message $site = ($allSites[$identifier] ?? null); if ($site instanceof Site && $site->getRootPageId() !== $rootPageId && ($mappingRootPageToSite[$rootPageId] ?? null) instanceof Site ) { // Find original value and keep this $origSite = $mappingRootPageToSite[$rootPageId]; $originalIdentifier = $identifier; $identifier = $origSite->getIdentifier(); $message = sprintf( $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:validation.identifierExists.message'), $originalIdentifier, $identifier ); $messageTitle = $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:validation.identifierExists.title'); $flashMessage = new FlashMessage($message, $messageTitle, ContextualFeedbackSeverity::WARNING, true); $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(); $defaultFlashMessageQueue->enqueue($flashMessage); } } return $identifier; } /** * Simple validation and processing method for incoming form field values. * * Note this does not support all TCA "eval" options but only what we really need. * * @param string $tableName Table name * @param string $fieldName Field name * @param mixed $fieldValue Incoming value from FormEngine * @param array $siteTca TCA for site * @return mixed Verified / modified value * @throws SiteValidationErrorException * @throws \RuntimeException */ protected function validateAndProcessValue(string $tableName, string $fieldName, $fieldValue, array $siteTca) { $languageService = $this->getLanguageService(); $fieldConfig = $siteTca[$tableName]['columns'][$fieldName]['config']; $handledEvals = []; if (!$this->validateValueForRequired($fieldConfig, $fieldValue)) { // Validation throws - these should be handled client side already, // eg. 'required' being set and receiving empty, shouldn't happen server side $message = sprintf( $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:validation.required.message'), $fieldName ); $messageTitle = $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:validation.required.title'); $flashMessage = new FlashMessage($message, $messageTitle, ContextualFeedbackSeverity::WARNING, true); $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(); $defaultFlashMessageQueue->enqueue($flashMessage); throw new SiteValidationErrorException( 'Field ' . $fieldName . ' is set to required, but received empty.', 1521726421 ); } if (!empty($fieldConfig['eval'])) { $evalArray = GeneralUtility::trimExplode(',', $fieldConfig['eval'], true); // Processing if (in_array('alphanum_x', $evalArray, true)) { $handledEvals[] = 'alphanum_x'; $fieldValue = preg_replace('/[^a-zA-Z0-9_-]/', '', $fieldValue); } if (in_array('lower', $evalArray, true)) { $handledEvals[] = 'lower'; $fieldValue = mb_strtolower($fieldValue, 'utf-8'); } if (in_array('trim', $evalArray, true)) { $handledEvals[] = 'trim'; $fieldValue = trim($fieldValue); } if (in_array('int', $evalArray, true)) { $handledEvals[] = 'int'; $fieldValue = (int)$fieldValue; } if (!empty(array_diff($evalArray, $handledEvals))) { throw new \RuntimeException('At least one not implemented \'eval\' in list ' . $fieldConfig['eval'], 1522491734); } } if (isset($fieldConfig['range']['lower'])) { $fieldValue = (int)$fieldValue < (int)$fieldConfig['range']['lower'] ? (int)$fieldConfig['range']['lower'] : (int)$fieldValue; } if (isset($fieldConfig['range']['upper'])) { $fieldValue = (int)$fieldValue > (int)$fieldConfig['range']['upper'] ? (int)$fieldConfig['range']['upper'] : (int)$fieldValue; } return $fieldValue; } /** * Last sanitation method after all data has been gathered. Check integrity * of full record, manipulate if possible, or throw exception if unfixable broken. * * @param array $newSysSiteData Incoming data * @param bool $isNewConfiguration Flag whether site configuration is new * @return array Updated data if needed * @throws \RuntimeException */ protected function validateFullStructure(array $newSysSiteData, bool $isNewConfiguration): array { $languageService = $this->getLanguageService(); // Verify there are not two error handlers with the same error code if (isset($newSysSiteData['errorHandling']) && is_array($newSysSiteData['errorHandling'])) { $uniqueCriteria = []; $validChildren = []; foreach ($newSysSiteData['errorHandling'] as $child) { if (!isset($child['errorCode'])) { throw new \RuntimeException('No errorCode found', 1521788518); } if (!in_array((int)$child['errorCode'], $uniqueCriteria, true)) { $uniqueCriteria[] = (int)$child['errorCode']; $child['errorCode'] = (int)$child['errorCode']; $validChildren[] = $child; } else { $message = sprintf( $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:validation.duplicateErrorCode.message'), $child['errorCode'] ); $messageTitle = $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:validation.duplicateErrorCode.title'); $flashMessage = new FlashMessage($message, $messageTitle, ContextualFeedbackSeverity::WARNING, true); $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(); $defaultFlashMessageQueue->enqueue($flashMessage); } } $newSysSiteData['errorHandling'] = $validChildren; } // Verify there is at least one site_language element configured. if (!isset($newSysSiteData['languages']) || !is_array($newSysSiteData['languages']) || count($newSysSiteData['languages']) < 1) { throw new \RuntimeException( 'No default language definition found. The interface does not allow this. Aborting', 1521789306 ); } $uniqueCriteria = []; $validChildren = []; foreach ($newSysSiteData['languages'] as $child) { if (!isset($child['languageId'])) { throw new \RuntimeException('languageId not found', 1521789455); } if (!in_array((int)$child['languageId'], $uniqueCriteria, true)) { $uniqueCriteria[] = (int)$child['languageId']; $child['languageId'] = (int)$child['languageId']; $validChildren[] = $child; } else { $message = sprintf( $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:validation.duplicateLanguageId.message'), $child['languageId'] ); $messageTitle = $languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:validation.duplicateLanguageId.title'); $flashMessage = new FlashMessage($message, $messageTitle, ContextualFeedbackSeverity::WARNING, true); $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(); $defaultFlashMessageQueue->enqueue($flashMessage); } } // On new site configurations, ensure that the only existing language has the languageId set to 0 // @todo: this shouldn't be done here, but rather properly handled in saveAction() where 'siteLanguage' is handled if ($isNewConfiguration && count($validChildren) === 1) { $validChildren[0]['languageId'] = 0; } $newSysSiteData['languages'] = $validChildren; // cleanup configuration foreach ($newSysSiteData as $identifier => $value) { if (is_array($value) && empty($value)) { unset($newSysSiteData[$identifier]); } } return $newSysSiteData; } /** * Delete an existing configuration */ public function deleteAction(ServerRequestInterface $request): ResponseInterface { $siteIdentifier = $request->getParsedBody()['site'] ?? ''; if (empty($siteIdentifier)) { throw new \RuntimeException('Not site identifier given', 1521565182); } try { // Verify site does exist, method throws if not $this->siteWriter->delete($siteIdentifier); $this->getBackendUser()->writelog(Type::SITE, SiteAction::DELETE, SystemLogErrorClassification::MESSAGE, null, 'Site configuration \'%s\' was deleted.', [$siteIdentifier], 'site'); } catch (SiteConfigurationWriteException $e) { $flashMessage = new FlashMessage($e->getMessage(), '', ContextualFeedbackSeverity::WARNING, true); $defaultFlashMessageQueue = $this->flashMessageService->getMessageQueueByIdentifier(); $defaultFlashMessageQueue->enqueue($flashMessage); } $overviewRoute = $this->uriBuilder->buildUriFromRoute('site_configuration'); return new RedirectResponse($overviewRoute); } /** * Create document header buttons of "detail" action */ protected function configureDetailViewDocHeader(ModuleTemplate $view, ?string $siteIdentifier, ServerRequestInterface $request): void { // Back button if ($returnUrl = $this->resolveReturnUrl($request)) { $view->addButtonToButtonBar($this->componentFactory->createBackButton($returnUrl)); } if ($siteIdentifier) { // 'Edit site configuration' button $editSiteConfigurationButton = $this->componentFactory->createLinkButton() ->setTitle($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:edit.site_configuration')) ->setIcon($this->iconFactory->getIcon('actions-open', IconSize::SMALL)) ->setShowLabelText(true) ->setHref((string)$this->uriBuilder->buildUriFromRoute('site_configuration.edit', [ 'site' => $siteIdentifier, 'returnUrl' => $this->uriBuilder->buildUriFromRoute('site_configuration.detail', [ 'site' => $siteIdentifier, ]), ])); $view->addButtonToButtonBar($editSiteConfigurationButton, ButtonBar::BUTTON_POSITION_LEFT, 3); // 'Edit site settings' button $editSiteSettingsButton = $this->componentFactory->createLinkButton() ->setTitle($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:edit.editSiteSettings')) ->setIcon($this->iconFactory->getIcon('actions-cog', IconSize::SMALL)) ->setShowLabelText(true) ->setHref((string)$this->uriBuilder->buildUriFromRoute('site_configuration.editSettings', [ 'site' => $siteIdentifier, 'returnUrl' => $this->uriBuilder->buildUriFromRoute('site_configuration.detail', [ 'site' => $siteIdentifier, ]), ])); $view->addButtonToButtonBar($editSiteSettingsButton, ButtonBar::BUTTON_POSITION_LEFT, 5); } // Set shortcut context - reload button is added automatically $view->getDocHeaderComponent()->setShortcutContext( 'site_configuration.detail', sprintf($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:labels.detail'), $siteIdentifier), ['site' => $siteIdentifier] ); } /** * Create document header buttons of "edit" action */ protected function configureEditViewDocHeader(ModuleTemplate $view, ?string $siteIdentifier, string $documentTitle = ''): void { $lang = $this->getLanguageService(); $closeButton = $this->componentFactory->createCloseButton('#') ->setClasses('t3js-editform-close'); $saveButton = $this->componentFactory->createSaveButton('siteConfigurationController'); $view->addButtonToButtonBar($closeButton); $view->addButtonToButtonBar($saveButton, ButtonBar::BUTTON_POSITION_LEFT, 2); if ($siteIdentifier) { $editSiteSettingsButton = $this->componentFactory->createLinkButton() ->setTitle($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration.xlf:edit.editSiteSettings')) ->setIcon($this->iconFactory->getIcon('actions-open', IconSize::SMALL)) ->setShowLabelText(true) ->setHref((string)$this->uriBuilder->buildUriFromRoute('site_configuration.editSettings', [ 'site' => $siteIdentifier, 'returnUrl' => $this->uriBuilder->buildUriFromRoute('site_configuration.edit', [ 'site' => $siteIdentifier, ]), ])); $view->addButtonToButtonBar($editSiteSettingsButton, ButtonBar::BUTTON_POSITION_LEFT, 3); } // Set shortcut context - reload button is added automatically $view->getDocHeaderComponent()->setShortcutContext( 'site_configuration.edit', $documentTitle, ['site' => $siteIdentifier], ); } /** * Resolves the document title used for the browser tab and shortcut. */ protected function resolveDocumentTitle(LanguageService $languageService, bool $isNewConfig, ?string $siteIdentifier): string { $typeLabel = $languageService->sL('backend.siteconfiguration:edit.typeLabel'); if ($isNewConfig) { return $languageService->sL('backend.siteconfiguration:edit.createNewSite'); } return implode(' ยท ', array_filter([$siteIdentifier, $typeLabel])); } /** * View mode */ protected function addDocHeaderViewModeButton(ModuleTemplate $moduleTemplate, SetupModuleViewMode $viewMode): void { $languageService = $this->getLanguageService(); $viewModeButton = $this->componentFactory->createDropDownButton() ->setLabel($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.view')) ->setIcon($this->iconFactory->getIcon('actions-cog')) ->setShowLabelText(true); $viewModeButton->addItem( $this->componentFactory->createDropDownRadio() ->setActive(($viewMode === SetupModuleViewMode::TILES)) ->setHref( (string)$this->uriBuilder->buildUriFromRoute( 'site_configuration', [ 'viewMode' => SetupModuleViewMode::TILES->value, ] ) ) ->setLabel($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.view.tiles')) ->setIcon($this->iconFactory->getIcon('actions-viewmode-tiles', IconSize::SMALL)) ); $viewModeButton->addItem( $this->componentFactory->createDropDownRadio() ->setActive(($viewMode === SetupModuleViewMode::LIST)) ->setHref( (string)$this->uriBuilder->buildUriFromRoute( 'site_configuration', [ 'viewMode' => SetupModuleViewMode::LIST->value, ] ) ) ->setLabel($languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.view.list')) ->setIcon($this->iconFactory->getIcon('actions-viewmode-list', IconSize::SMALL)) ); $moduleTemplate->addButtonToButtonBar($viewModeButton, ButtonBar::BUTTON_POSITION_RIGHT, 2); } /** * Returns a list of pages that have 'is_siteroot' set * or are on pid 0 and not in list of excluded doktypes */ protected function getAllSitePages(): array { $queryBuilder = $this->connectionPool->getQueryBuilderForTable('pages'); $queryBuilder->getRestrictions()->removeAll(); $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(DeletedRestriction::class)); $queryBuilder->getRestrictions()->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, 0)); $statement = $queryBuilder ->select('*') ->from('pages') ->where( $queryBuilder->expr()->or( $queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter(\Local\Multilanguage\Service\DefaultLanguageTagService::getTag())), $queryBuilder->expr()->eq('language_tag', $queryBuilder->createNamedParameter('')), ), $queryBuilder->expr()->or( $queryBuilder->expr()->and( $queryBuilder->expr()->eq('pid', 0), $queryBuilder->expr()->notIn('doktype', [ PageRepository::DOKTYPE_SYSFOLDER, PageRepository::DOKTYPE_SPACER, PageRepository::DOKTYPE_LINK, ]) ), $queryBuilder->expr()->eq('is_siteroot', 1) ) ) ->orderBy('pid') ->addOrderBy('sorting') ->executeQuery(); $pages = []; while ($row = $statement->fetchAssociative()) { $row['rootline'] = BackendUtility::BEgetRootLine((int)$row['uid']); array_pop($row['rootline']); $row['rootline'] = array_reverse($row['rootline']); $pages[(int)$row['uid']] = $row; } return $pages; } /** * Get all entry duplicates which are used multiple times * * @param Site[] $allSites */ protected function getDuplicatedEntryPoints(array $allSites, array $pages): array { $duplicatedEntryPoints = []; foreach ($allSites as $site) { if (!isset($pages[$site->getRootPageId()])) { continue; } foreach ($site->getAllLanguages() as $language) { $base = $language->getBase(); $entryPoint = rtrim((string)$language->getBase(), '/'); $scheme = $base->getScheme() ? $base->getScheme() . '://' : '//'; $entryPointWithoutScheme = str_replace($scheme, '', $entryPoint); if (!isset($duplicatedEntryPoints[$entryPointWithoutScheme][$entryPoint])) { $duplicatedEntryPoints[$entryPointWithoutScheme][$entryPoint] = 1; } else { $duplicatedEntryPoints[$entryPointWithoutScheme][$entryPoint]++; } } } return array_filter($duplicatedEntryPoints, static function (array $variants): bool { return count($variants) > 1 || reset($variants) > 1; }, ARRAY_FILTER_USE_BOTH); } /** * Returns the last (highest) language id from all sites */ protected function getLastLanguageId(): int { $lastLanguageId = 0; foreach ($this->siteFinder->getAllSites() as $site) { foreach ($site->getAllLanguages() as $language) { if ($language->getLanguageId() > $lastLanguageId) { $lastLanguageId = $language->getLanguageId(); } } } return $lastLanguageId; } /** * Checks if required=TRUE is set. * If set: checks if the value is not empty (or not "0"). * If not set or set to FALSE: Returns TRUE. */ protected function validateValueForRequired(array $tcaFieldConfig, mixed $value): bool { if (!($tcaFieldConfig['required'] ?? false)) { return true; } return !empty($value) || $value === '0'; } /** * Method keeps root config objects, which are not given via GUI. This way, * extension authors are able to use their own objects on root level that are * not configurable via GUI. However: We overwrite the full subset of any GUI * object to make sure we have a clean state. * * Additionally, we also keep the baseVariants of languages, since they * can't be modified via the GUI, but are part of the public API. */ protected function getMergeSiteData(array $currentSiteConfiguration, array $newSysSiteData): array { $newSysSiteData = array_merge($currentSiteConfiguration, $newSysSiteData); // @todo: this should go away, once base variants for languages are managable via the GUI. $existingLanguageConfigurationsWithBaseVariants = []; $existingLanguagesWithLegacyProperties = []; foreach ($currentSiteConfiguration['languages'] ?? [] as $languageConfiguration) { if (isset($languageConfiguration['baseVariants'])) { $existingLanguageConfigurationsWithBaseVariants[$languageConfiguration['languageId']] = $languageConfiguration['baseVariants']; } if (isset($languageConfiguration['typo3Language'])) { $existingLanguagesWithLegacyProperties[$languageConfiguration['languageId']]['typo3Language'] = $languageConfiguration['typo3Language']; } if (isset($languageConfiguration['iso-639-1'])) { $existingLanguagesWithLegacyProperties[$languageConfiguration['languageId']]['iso-639-1'] = $languageConfiguration['iso-639-1']; } if (isset($languageConfiguration['direction'])) { $existingLanguagesWithLegacyProperties[$languageConfiguration['languageId']]['direction'] = $languageConfiguration['direction']; } } foreach ($newSysSiteData['languages'] ?? [] as $key => $languageConfiguration) { $languageId = $languageConfiguration['languageId']; if (isset($existingLanguageConfigurationsWithBaseVariants[$languageId])) { $newSysSiteData['languages'][$key]['baseVariants'] = $existingLanguageConfigurationsWithBaseVariants[$languageId]; } foreach ($existingLanguagesWithLegacyProperties[$languageId] ?? [] as $propertyName => $propertyValue) { $newSysSiteData['languages'][$key][$propertyName] = $propertyValue; } } return $newSysSiteData; } private function resolveSettingLabels(SettingDefinition $definition): SettingDefinition { $languageService = $this->getLanguageService(); return new SettingDefinition(...[ ...get_object_vars($definition), 'label' => $languageService->sL($definition->label), 'description' => $definition->description !== null ? $languageService->sL($definition->description) : null, 'enum' => array_map( static fn(string|int|float|bool $label): string => $languageService->sL((string)$label), $definition->enum ), ]); } protected function resolveReturnUrl(ServerRequestInterface $request): string { return GeneralUtility::sanitizeLocalUrl( (string)($request->getParsedBody()['returnUrl'] ?? $request->getQueryParams()['returnUrl'] ?? ''), $request ) ?: (string)$this->uriBuilder->buildUriFromRoute('site_configuration'); } protected function getLanguageService(): LanguageService { return $GLOBALS['LANG']; } protected function getBackendUser(): BackendUserAuthentication { return $GLOBALS['BE_USER']; } }