, * active: array * } */ #[Autoconfigure(public: true)] readonly class SetupService { public function __construct( private ConfigurationManager $configurationManager, private SiteWriter $siteWriter, private YamlFileLoader $yamlFileLoader, private PackageManager $packageManager, private ConnectionPool $connectionPool, private ClearCacheService $clearCacheService, ) {} /** * @param WebserverType $webserverType * @return FlashMessage[] */ public function createDirectoryStructure(WebserverType $webserverType): array { $folderStructureFactory = GeneralUtility::makeInstance(DefaultFactory::class); $structureFixMessageQueue = $folderStructureFactory->getStructure($webserverType)->fix(); return $structureFixMessageQueue->getAllMessages(ContextualFeedbackSeverity::ERROR); } public function setSiteName(string $name): bool { return $this->configurationManager->setLocalConfigurationValueByPath('SYS/sitename', $name); } /** * Creates a site configuration with one language "English" which is the de-facto default language for TYPO3 in general. * * @param string[] $dependencies Site set identifiers to add as dependencies * @throws SiteConfigurationWriteException */ private function createSiteConfiguration(string $identifier, int $rootPageId, string $siteUrl, array $dependencies = []): void { // Create a default site configuration called "main" as best practice $this->siteWriter->createNewBasicSite($identifier, $rootPageId, $siteUrl, $dependencies); } /** * Returns all available packages that ship initialisation data (data.xml or data.t3d) * which can (or will) be imported during installation. * * @return SplitDistributions */ public function getAvailableDistributions(): array { $distributions = [ 'inactive' => [], 'active' => [], ]; $packages = $this->packageManager->getAvailablePackages(); // Prefer framework packages uasort($packages, static fn(PackageInterface $packageA, PackageInterface $packageB) => $packageA->getPackageMetaData()->isFrameworkType() !== $packageB->getPackageMetaData()->isFrameworkType() ? $packageB->getPackageMetaData()->isFrameworkType() <=> $packageA->getPackageMetaData()->isFrameworkType() : $packageA->getPackageKey() <=> $packageB->getPackageKey()); foreach ($packages as $packageKey => $package) { $packagePath = $package->getPackagePath(); if (!file_exists($packagePath . 'Initialisation/data.xml') && !file_exists($packagePath . 'Initialisation/data.t3d') ) { continue; } $metaData = $package->getPackageMetaData(); $activeKey = $this->packageManager->isPackageActive($packageKey) ? 'active' : 'inactive'; $distributions[$activeKey][$packageKey] = [ 'packageKey' => $packageKey, 'title' => $metaData->getTitle() ?? $packageKey, 'description' => $metaData->getDescription() ?? '', 'isFramework' => $metaData->isFrameworkType(), ]; } return $distributions; } /** * This function returns a salted hashed key for new backend user password and install tool password. * * This method is executed during installation *before* the preset did set up proper hash method * selection in LocalConfiguration. So PasswordHashFactory is not usable at this point. We thus loop through * the default hash mechanisms and select the first one that works. The preset calculation of step * executeDefaultConfigurationAction() basically does the same later. * * @param string $password Plain text password * @return string Hashed password */ private function getHashedPassword(string $password): string { $okHashMethods = [ Argon2iPasswordHash::class, Argon2idPasswordHash::class, BcryptPasswordHash::class, ]; foreach ($okHashMethods as $className) { /** @var PasswordHashInterface $instance */ $instance = GeneralUtility::makeInstance($className); if ($instance->isAvailable()) { return $instance->getHashedPassword($password); } } // Should never happen since bcrypt is always available throw new InvalidPasswordHashException('No suitable hash method found', 1533988846); } /** * Create a backend user with maintainer and admin flag * set by default, because the initial user always requires * these flags to grant full permissions to the system. */ public function createUser(string $username, string $password, string $email = ''): void { $adminUserFields = [ 'username' => $username, 'password' => $this->getHashedPassword($password), 'email' => GeneralUtility::validEmail($email) ? $email : '', 'admin' => 1, 'tstamp' => $GLOBALS['EXEC_TIME'], 'crdate' => $GLOBALS['EXEC_TIME'], ]; $databaseConnection = $this->connectionPool->getConnectionForTable('be_users'); $databaseConnection->insert('be_users', $adminUserFields); $adminUserUid = (int)$databaseConnection->lastInsertId(); $maintainerIds = $this->configurationManager->getConfigurationValueByPath('SYS/systemMaintainers') ?? []; sort($maintainerIds); $maintainerIds[] = $adminUserUid; $this->configurationManager->setLocalConfigurationValuesByPathValuePairs([ 'SYS/systemMaintainers' => array_unique($maintainerIds), ]); } public function setInstallToolPassword(string $password): bool { return $this->configurationManager->setLocalConfigurationValuesByPathValuePairs([ 'BE/installToolPassword' => $this->getHashedPassword($password), ]); } /** * @throws ConfigurationFileAlreadyExistsException * @throws ConfigurationDirectoryDoesNotExistException */ public function prepareSystemSettings(bool $forceOverwrite = false): void { $configurationFileLocation = $this->configurationManager->getSystemConfigurationFileLocation(); $configDir = dirname($configurationFileLocation); if (!is_dir($configDir)) { throw new ConfigurationDirectoryDoesNotExistException( 'Configuration directory ' . $this->makePathRelativeToProjectDirectory($configDir) . ' does not exist!', 1700401774, ); } if (@is_file($configurationFileLocation)) { if (!$forceOverwrite) { throw new ConfigurationFileAlreadyExistsException( 'Configuration file ' . $this->makePathRelativeToProjectDirectory($configurationFileLocation) . ' already exists!', 1669747685, ); } unlink($configurationFileLocation); } $this->configurationManager->createLocalConfigurationFromFactoryConfiguration(); $randomKey = GeneralUtility::makeInstance(Random::class)->generateRandomHexString(96); $this->configurationManager->setLocalConfigurationValueByPath('SYS/encryptionKey', $randomKey); $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = $randomKey; // Get best matching configuration presets $featureManager = new FeatureManager(); $configurationValues = $featureManager->getBestMatchingConfigurationForAllFeatures(); $this->configurationManager->setLocalConfigurationValuesByPathValuePairs($configurationValues); if ($this->packageManager instanceof FailsafePackageManager) { // Disable failsafe mode to allow persistence of PackageStates changes $this->packageManager->disableFailsafeMode(); } // In non Composer mode, create a PackageStates.php with all packages activated marked as "part of factory default" $this->packageManager->recreatePackageStatesFileIfMissing(true); } /** * Create a root page and site configuration with appropriate site set dependencies, if available */ public function createSite(string $siteIdentifier, string $siteUrl): int { $databaseConnectionForPages = $this->connectionPool->getConnectionForTable('pages'); $databaseConnectionForPages->insert( 'pages', [ 'pid' => 0, 'crdate' => time(), 'tstamp' => time(), 'title' => 'Home', 'slug' => '/', 'doktype' => 1, 'is_siteroot' => 1, 'perms_userid' => 1, 'perms_groupid' => 1, 'perms_user' => 31, 'perms_group' => 31, 'perms_everybody' => 1, ] ); $pageId = (int)$databaseConnectionForPages->lastInsertId(); $databaseConnectionForContent = $this->connectionPool->getConnectionForTable('tt_content'); $databaseConnectionForContent->insert( 'tt_content', [ 'pid' => $pageId, 'crdate' => time(), 'tstamp' => time(), 'CType' => 'text', 'colPos' => 0, 'header' => 'Welcome to your default website', 'bodytext' => '

This website is made with TYPO3.

', ] ); $dependencies = []; if ($this->packageManager->isPackageActive('fluid_styled_content')) { $dependencies = ['typo3/fluid-styled-content', 'typo3/fluid-styled-content-css']; } $this->createSiteConfiguration($siteIdentifier, $pageId, $siteUrl, $dependencies); $this->writeSiteSetupTypoScript($siteIdentifier); return $pageId; } /** * Activate the selected distribution package in case it isn't already * and make sure import export package is installed as well */ public function activateDistributionPackage(string $packageKey): void { if ($this->packageManager->isPackageActive($packageKey) || !$this->packageManager->isPackageActive('impexp') ) { return; } // We don't end up here in Composer mode, // because a Composer installed packages are always active $this->packageManager->activatePackage($packageKey); // Make sure DI cache is flushed to get the TCA Schema including the new extension $this->clearCacheService->clearAll(); // Make sure class loading information is present in case // a third party distribution with classes is activated $this->dumpClassLoadingInformationForAllPackages(); } private function dumpClassLoadingInformationForAllPackages(): void { if (Environment::isComposerMode()) { return; } ClassLoadingInformation::dumpClassLoadingInformation(); } /** * Writes a setup.typoscript file to the site configuration directory with basic PAGE rendering. */ private function writeSiteSetupTypoScript(string $siteIdentifier): void { $siteConfigPath = Environment::getConfigPath() . '/sites/' . $siteIdentifier; $typoScriptContent = <<<'TYPOSCRIPT' page = PAGE page.10 = COA page.10.stdWrap.wrap =
|
page.10.10 = TEXT page.10.10.value (
) page.10.20 = CONTENT page.10.20 { table = tt_content select { orderBy = sorting where = {#colPos}=0 } } TYPOSCRIPT; GeneralUtility::writeFile($siteConfigPath . '/setup.typoscript', $typoScriptContent); } /** * Initializes backend user group presets. Currently hard-coded to editor and advanced editor. * When more backend user group presets are added, please refactor (maybe DTO). * * @return string[] */ public function createBackendUserGroups(bool $createEditor = true, bool $createAdvancedEditor = true, bool $force = false): array { $messages = []; $this->createFileMount('1:/user_upload/', 'User Upload'); if ($createEditor) { if (!$force && $this->countBackendGroupsByTitle($this->connectionPool, BackendUserGroupType::EDITOR->value) > 0) { $messages[] = sprintf('Group "%s" could not be created. A backend user group of that name already exists and option --force was not set. ', BackendUserGroupType::EDITOR->value); } else { $this->connectionPool->getConnectionForTable('be_groups')->insert( 'be_groups', [ 'title' => BackendUserGroupType::EDITOR->value, 'description' => 'Editors have access to basic content element and modules in the backend.', 'tstamp' => time(), 'crdate' => time(), ] ); $editorGroupUid = (int)$this->connectionPool->getConnectionForTable('be_groups')->lastInsertId(); $editorPermissionPreset = $this->yamlFileLoader->load('EXT:install/Configuration/PermissionPreset/be_groups_editor.yaml'); $this->applyPermissionPreset($editorPermissionPreset, 'be_groups', $editorGroupUid); } } if ($createAdvancedEditor) { if (!$force && $this->countBackendGroupsByTitle($this->connectionPool, BackendUserGroupType::ADVANCED_EDITOR->value) > 0) { $messages[] = sprintf('Group "%s" could not be created. A backend user group of that name already exists and option --force was not set. ', BackendUserGroupType::ADVANCED_EDITOR->value); } else { $this->connectionPool->getConnectionForTable('be_groups')->insert( 'be_groups', [ 'title' => BackendUserGroupType::ADVANCED_EDITOR->value, 'description' => 'Advanced Editors have access to all content elements and non administrative modules in the backend.', 'tstamp' => time(), 'crdate' => time(), ] ); $advancedEditorGroupUid = (int)$this->connectionPool->getConnectionForTable('be_groups')->lastInsertId(); $advancedEditorPermissionPreset = $this->yamlFileLoader->load('EXT:install/Configuration/PermissionPreset/be_groups_advanced_editor.yaml'); $this->applyPermissionPreset($advancedEditorPermissionPreset, 'be_groups', $advancedEditorGroupUid); } } return $messages; } public function setupExtensions(ContainerInterface $container): void { // Import of distribution data needs DataHandler and thus an initialized backend user // Maybe this would be cleaner if the setup process could execute commands in a sub process, // but this has other drawbacks and is for another day $this->executeWithBackendUser( function (ContainerInterface $container) { $extensionsToSetUp = $this->packageManager->getActivePackages(true); $container->get(PackageSetup::class)->setup($extensionsToSetUp); }, $container, ); } /** * Bootstrap a backend user context required e.g. for extension activation * when an import is preformed, which uses DataHandler, that requires * a user to exist */ private function executeWithBackendUser(\Closure $executor, ContainerInterface $container): mixed { $previousBackendUser = $GLOBALS['BE_USER'] ?? null; $previousLanguageService = $GLOBALS['LANG'] ?? null; $connectionPool = $container->get(ConnectionPool::class); $GLOBALS['BE_USER'] = $previousBackendUser ?? $this->createBackendUser($connectionPool); $GLOBALS['LANG'] = $previousLanguageService ?? $container->get(LanguageServiceFactory::class)->create('en'); try { return $executor($container); } finally { $GLOBALS['BE_USER'] = $previousBackendUser; $GLOBALS['LANG'] = $previousLanguageService; } } private function createBackendUser(ConnectionPool $connectionPool): BackendUserAuthentication { $backendUser = new BackendUserAuthentication(); $backendUser->user = $this->getFirstAdminUser($connectionPool); $backendUser->workspace = 0; return $backendUser; } private function applyPermissionPreset(array $permissionPreset, string $table, int $recordId): void { $mappedPermissions = []; if (isset($permissionPreset['dbMountpoints']) && is_array($permissionPreset['dbMountpoints'])) { $mappedPermissions['db_mountpoints'] = implode(',', $permissionPreset['dbMountpoints']); } if (isset($permissionPreset['fileMountpoints']) && is_array($permissionPreset['fileMountpoints'])) { $fileMountIds = []; foreach ($permissionPreset['fileMountpoints'] as $fileMountpoint) { $fileMountpointId = $this->getFileMount($fileMountpoint); if ($fileMountpointId > 0) { $fileMountIds[] = $fileMountpointId; } } $mappedPermissions['file_mountpoints'] = implode(',', $fileMountIds); } if (isset($permissionPreset['groupMods']) && is_array($permissionPreset['groupMods'])) { $mappedPermissions['groupMods'] = implode(',', $permissionPreset['groupMods']); } if (isset($permissionPreset['pageTypesSelect']) && is_array($permissionPreset['pageTypesSelect'])) { $mappedPermissions['pagetypes_select'] = implode(',', $permissionPreset['pageTypesSelect']); } if (isset($permissionPreset['tablesModify']) && is_array($permissionPreset['tablesModify'])) { $mappedPermissions['tables_modify'] = implode(',', $permissionPreset['tablesModify']); } if (isset($permissionPreset['tablesSelect']) && is_array($permissionPreset['tablesSelect'])) { $mappedPermissions['tables_select'] = implode(',', $permissionPreset['tablesSelect']); } if (isset($permissionPreset['nonExcludeFields']) && is_array($permissionPreset['nonExcludeFields'])) { $nonExcludeFields = []; foreach ($permissionPreset['nonExcludeFields'] as $tableName => $fields) { foreach ($fields as $field) { $nonExcludeFields[] = "$tableName:$field"; } } if ($nonExcludeFields !== []) { $mappedPermissions['non_exclude_fields'] = implode(',', $nonExcludeFields); } } if (isset($permissionPreset['explicitAllowDeny']) && is_array($permissionPreset['explicitAllowDeny'])) { $explicitAllowDeny = []; foreach ($permissionPreset['explicitAllowDeny'] as $tableName => $columns) { foreach ($columns as $column => $values) { foreach ($values as $value) { $explicitAllowDeny[] = "$tableName:$column:$value"; } } } if ($explicitAllowDeny !== []) { $mappedPermissions['explicit_allowdeny'] = implode(',', $explicitAllowDeny); } } $databaseConnection = $this->connectionPool->getConnectionForTable($table); if ( // availableWidgets is only available if typo3/cms-dashboard is installed $databaseConnection->getSchemaInformation()->getTableInfo($table)->hasColumnInfo('availableWidgets') && isset($permissionPreset['availableWidgets']) && is_array($permissionPreset['availableWidgets']) ) { $mappedPermissions['availableWidgets'] = implode(',', $permissionPreset['availableWidgets']); } if ($mappedPermissions !== []) { $databaseConnection->update( $table, $mappedPermissions, ['uid' => $recordId] ); } } private function getFirstAdminUser(ConnectionPool $connectionPool): array { $queryBuilder = $connectionPool->getQueryBuilderForTable('be_users'); $row = $queryBuilder->select('*') ->from('be_users') ->where( $queryBuilder->expr()->eq('admin', $queryBuilder->createNamedParameter(1, \Doctrine\DBAL\ParameterType::INTEGER)) ) ->setMaxResults(1) ->executeQuery() ->fetchAssociative(); if (!is_array($row)) { throw new \RuntimeException('No admin backend user found for import context', 1743400000); } return $row; } private function makePathRelativeToProjectDirectory(string $absolutePath): string { return str_replace(Environment::getProjectPath(), '', $absolutePath); } private function countBackendGroupsByTitle(ConnectionPool $connectionPool, string $title): int { $queryBuilder = $this->connectionPool->getQueryBuilderForTable('be_groups'); return (int)$queryBuilder->count('*') ->from('be_groups') ->where( $queryBuilder->expr()->eq('title', $queryBuilder->createNamedParameter($title)) )->executeQuery()->fetchOne(); } private function createFileMount(string $identifier, string $title): int { $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_filemounts'); $row = $queryBuilder->select('uid') ->from('sys_filemounts') ->where($queryBuilder->expr()->eq('identifier', $queryBuilder->createNamedParameter($identifier))) ->executeQuery() ->fetchAssociative(); if (is_array($row)) { return (int)$row['uid']; } $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_filemounts'); $queryBuilder->insert('sys_filemounts')->values( [ 'pid' => 0, 'tstamp' => time(), 'title' => $title, 'identifier' => $identifier, ] )->executeStatement(); return (int)$this->connectionPool->getConnectionForTable('sys_filemounts')->lastInsertId(); } private function getFileMount(string $identifier): int { $queryBuilder = $this->connectionPool->getQueryBuilderForTable('sys_filemounts'); $row = $queryBuilder->select('uid') ->from('sys_filemounts') ->where($queryBuilder->expr()->eq('identifier', $queryBuilder->createNamedParameter($identifier))) ->executeQuery() ->fetchAssociative(); return (int)($row['uid'] ?? 0); } }