TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:31 +02:00
commit 3e43c11539
407 changed files with 51272 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\DependencyInjection\Cache\ContainerBackend;
/**
* Basic service to clear caches within the install tool.
* @internal This is NOT an API class, it is for internal use in the install tool only.
*/
readonly class ClearCacheService
{
public function __construct(
private LateBootService $lateBootService,
private FrontendInterface $dependencyInjectionCache
) {}
/**
* This clear cache implementation follows a pretty brutal approach.
* Goal is to reliably get rid of cache entries, even if some broken
* extension is loaded that would kill the backend 'clear cache' action.
*
* Therefore, this method "knows" implementation details of the cache
* framework and uses them to clear all file based cache (typo3temp/Cache)
* and database caches (tables prefixed with cf_) manually.
*
* After that ext_localconf of extensions are loaded, those
* may register additional caches in the caching framework with different
* backend, and will then clear them with the usual flush() method.
*/
public function clearAll(): void
{
// Flush all caches defined in TYPO3_CONF_VARS, but not the ones defined by extensions in ext_localconf.php
$baseCaches = $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations'] ?? [];
// Remove DI container cache (will be renewed in next step)
if ($this->dependencyInjectionCache->getBackend() instanceof ContainerBackend) {
/** @var ContainerBackend $diCacheBackend */
$diCacheBackend = $this->dependencyInjectionCache->getBackend();
// We need to remove using the forceFlush method because the DI cache backend disables the flush method
$diCacheBackend->forceFlush();
}
// The cache manager is already instantiated in the install tool
// * (both in the failsafe and the late boot container), but
// * with settings to disable caching (all caches using NullBackend).
// Obtain a real instance
$this->lateBootService->unsetInternalContainerInstance();
$container = $this->lateBootService->getContainer(true);
$this->lateBootService->makeCurrent($container);
$cacheManager = $container->get(CacheManager::class);
$cacheManager->flushCaches();
// From this point on, the code may fatal, if some broken extension is loaded.
$this->lateBootService->loadExtLocalconfDatabase();
$extensionCaches = $GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations'] ?? [];
// Loose comparison on purpose to allow changed ordering of the array
if ($baseCaches != $extensionCaches) {
// When configuration has changed during loading of extensions (due to ext_localconf.php), flush all caches again
$cacheManager->setCacheConfigurations($GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']['cacheConfigurations']);
$cacheManager->flushCaches();
}
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Package\PackageManager;
/**
* Service handling clearing and statistics of semi-persistent core tables.
*
* @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
*/
#[Autoconfigure(public: true)]
final readonly class ClearTableService
{
public function __construct(
private ConnectionPool $connectionPool,
private PackageManager $packageManager,
) {}
/**
* Get an array of all affected tables, a short description and their row counts
*/
public function getTableStatistics(): array
{
$tableStatistics = [];
foreach ($this->getTableList() as $table) {
$connection = $this->connectionPool->getConnectionForTable($table['name']);
if ($connection->createSchemaManager()->tablesExist([$table['name']])) {
$table['rowCount'] = $connection->count(
'*',
$table['name'],
[]
);
$tableStatistics[] = $table;
}
}
return $tableStatistics;
}
/**
* Truncate a table from $this->tableList
*/
public function clearSelectedTable(string $tableName): void
{
$tableFound = false;
foreach ($this->getTableList() as $table) {
if ($table['name'] === $tableName) {
$tableFound = true;
break;
}
}
if (!$tableFound) {
throw new \RuntimeException(
'Selected table ' . $tableName . ' can not be cleared',
1501942151
);
}
$this->connectionPool->getConnectionForTable($tableName)->truncate($tableName);
}
/**
* List of tables and their description
*/
private function getTableList(): array
{
$tableList = [
[
'name' => 'be_sessions',
'description' => 'Backend user sessions',
],
[
'name' => 'fe_sessions',
'description' => 'Frontend user sessions',
],
[
'name' => 'sys_lockedrecords',
'description' => 'Record locking of backend user editing',
],
[
'name' => 'sys_http_report',
'description' => 'Requests with Content-Security-Policy Reports',
],
[
'name' => 'sys_log',
'description' => 'General log table',
],
];
if ($this->packageManager->isPackageActive('workspaces')) {
$tableList[] = [
'name' => 'sys_preview',
'description' => 'Workspace preview links',
];
}
if ($this->packageManager->isPackageActive('extensionmanager')) {
$tableList[] = [
'name' => 'tx_extensionmanager_domain_model_extension',
'description' => 'List of TER extensions',
];
}
return $tableList;
}
}
+531
View File
@@ -0,0 +1,531 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
use TYPO3\CMS\Core\Service\OpcodeCacheService;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
use TYPO3\CMS\Install\CoreVersion\CoreRelease;
use TYPO3\CMS\Install\FolderStructure\DefaultFactory;
use TYPO3\CMS\Install\WebserverType;
/**
* Core update service.
* This service handles core updates, all the nasty details are encapsulated
* here. The single public methods 'depend' on each other, for example a new
* core has to be downloaded before it can be unpacked.
*
* Each method returns only TRUE of FALSE indicating if it was successful or
* not. Detailed information can be fetched with getMessages() and will return
* a list of status messages of the previous operation.
* @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
*/
class CoreUpdateService
{
/**
* @var FlashMessageQueue
*/
protected $messages;
/**
* Absolute path to download location
*
* @var string
*/
protected $downloadTargetPath;
/**
* Absolute path to the symlink pointing to the currently used TYPO3 core files
*
* @var string
*/
protected $symlinkToCoreFiles;
/**
* Base URI for TYPO3 downloads
*
* @var string
*/
protected $downloadBaseUri;
public function __construct(protected readonly CoreVersionService $coreVersionService)
{
$this->setDownloadTargetPath(Environment::getVarPath() . '/transient/');
$this->symlinkToCoreFiles = $this->discoverCurrentCoreSymlink();
$this->downloadBaseUri = 'https://get.typo3.org';
$this->messages = new FlashMessageQueue('install');
}
/**
* Check if this installation wants to enable the core updater
*
* @return bool
*/
public function isCoreUpdateEnabled()
{
$coreUpdateDisabled = getenv('TYPO3_DISABLE_CORE_UPDATER') ?: (getenv('REDIRECT_TYPO3_DISABLE_CORE_UPDATER') ?: false);
return !Environment::isComposerMode() && !$coreUpdateDisabled;
}
/**
* In future implementations we might implement some smarter logic here
*
* @return string
*/
protected function discoverCurrentCoreSymlink()
{
return Environment::getPublicPath() . '/typo3_src';
}
/**
* Create download location in case the folder does not exist
* @todo move this to folder structure
*
* @param string $downloadTargetPath
*/
protected function setDownloadTargetPath($downloadTargetPath)
{
if (!is_dir($downloadTargetPath)) {
GeneralUtility::mkdir_deep($downloadTargetPath);
}
$this->downloadTargetPath = $downloadTargetPath;
}
/**
* Get messages of previous method call
*/
public function getMessages(): FlashMessageQueue
{
return $this->messages;
}
/**
* Check if an update is possible at all
*
* @param CoreRelease $coreRelease The target core release
* @param WebserverType $webserverType The webserver type.
* @return bool TRUE on success
*/
public function checkPreConditions(CoreRelease $coreRelease, WebserverType $webserverType)
{
$success = true;
// Folder structure test: Update can be done only if folder structure returns no errors
$folderStructureFacade = GeneralUtility::makeInstance(DefaultFactory::class)->getStructure($webserverType);
$folderStructureMessageQueue = $folderStructureFacade->getStatus();
$folderStructureErrors = $folderStructureMessageQueue->getAllMessages(ContextualFeedbackSeverity::ERROR);
$folderStructureWarnings = $folderStructureMessageQueue->getAllMessages(ContextualFeedbackSeverity::WARNING);
if (!empty($folderStructureErrors) || !empty($folderStructureWarnings) || !is_link(Environment::getPublicPath() . '/typo3_src')) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'To perform an update, the folder structure of this TYPO3 CMS instance must'
. ' stick to the conventions, or the update process could lead to unexpected results'
. ' and may be hazardous to your system. Please check your directory status in the'
. ' “Environment” module under “Directory Status”.',
'Automatic TYPO3 CMS core update not possible: Folder structure has errors or warnings',
ContextualFeedbackSeverity::ERROR
));
}
// No core update on windows
if (Environment::isWindows()) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'',
'Automatic TYPO3 CMS core update not possible: Update not supported on Windows OS',
ContextualFeedbackSeverity::ERROR
));
}
if ($success) {
// Explicit write check to document root
$file = Environment::getPublicPath() . '/' . StringUtility::getUniqueId('install-core-update-test-');
$result = @touch($file);
if (!$result) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'Could not write a file in path "' . Environment::getPublicPath() . '/"!'
. ' Please check your directory status in the “Environment” module under “Directory Status”.',
'Automatic TYPO3 CMS core update not possible: No write access to document root',
ContextualFeedbackSeverity::ERROR
));
} else {
// Check symlink creation
$link = Environment::getPublicPath() . '/' . StringUtility::getUniqueId('install-core-update-test-');
@symlink($file, $link);
if (!is_link($link)) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'Could not create a symbolic link in path "' . Environment::getPublicPath() . '/"!'
. ' Please check your directory status in the “Environment” module under “Directory Status”.',
'Automatic TYPO3 CMS core update not possible: No symlink creation possible',
ContextualFeedbackSeverity::ERROR
));
} else {
unlink($link);
}
unlink($file);
}
if (!$this->checkCoreFilesAvailable($coreRelease->getVersion())) {
// Explicit write check to upper directory of current core location
$coreLocation = @realpath($this->symlinkToCoreFiles . '/../');
$file = $coreLocation . '/' . StringUtility::getUniqueId('install-core-update-test-');
$result = @touch($file);
if (!$result) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'New TYPO3 CMS core should be installed in "' . $coreLocation . '", but this directory is not writable!'
. ' Please check your directory status in the “Environment” module under “Directory Status”.',
'Automatic TYPO3 CMS core update not possible: No write access to TYPO3 CMS core location',
ContextualFeedbackSeverity::ERROR
));
} else {
unlink($file);
}
}
}
if ($success && !$this->coreVersionService->isInstalledVersionAReleasedVersion()) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'Your current version is specified as ' . $this->coreVersionService->getInstalledVersion() . '.'
. ' This is a development version and can not be updated automatically. If this is a "git"'
. ' checkout, please update using git directly.',
'Automatic TYPO3 CMS core update not possible: You are running a development version of TYPO3',
ContextualFeedbackSeverity::ERROR
));
}
return $success;
}
/**
* Download the specified version
*
* @param CoreRelease $coreRelease A core release to download
* @return bool TRUE on success
*/
public function downloadVersion(CoreRelease $coreRelease)
{
$version = $coreRelease->getVersion();
$success = true;
if ($this->checkCoreFilesAvailable($version)) {
$this->messages->enqueue(new FlashMessage(
'',
'Skipped download of TYPO3 CMS core. A core source directory already exists in destination path. Using this instead.',
ContextualFeedbackSeverity::NOTICE
));
} else {
$downloadUri = $this->downloadBaseUri . '/' . $version;
$fileLocation = $this->getDownloadTarGzTargetPath($version);
if (@file_exists($fileLocation)) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'',
'TYPO3 CMS core download exists in download location: ' . PathUtility::stripPathSitePrefix($this->downloadTargetPath),
ContextualFeedbackSeverity::ERROR
));
} else {
$fileContent = GeneralUtility::getUrl($downloadUri);
if (!$fileContent) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'Failed to download ' . $downloadUri,
'Download not successful',
ContextualFeedbackSeverity::ERROR
));
} else {
$fileStoreResult = file_put_contents($fileLocation, $fileContent);
if (!$fileStoreResult) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'',
'Unable to store download content',
ContextualFeedbackSeverity::ERROR
));
} else {
$this->messages->enqueue(new FlashMessage(
'',
'TYPO3 CMS core download finished'
));
}
}
}
}
return $success;
}
/**
* Verify checksum of downloaded version
*
* @param CoreRelease $coreRelease A downloaded core release to check
* @return bool TRUE on success
*/
public function verifyFileChecksum(CoreRelease $coreRelease)
{
$version = $coreRelease->getVersion();
$success = true;
if ($this->checkCoreFilesAvailable($version)) {
$this->messages->enqueue(new FlashMessage(
'',
'Verifying existing TYPO3 CMS core checksum is not possible',
ContextualFeedbackSeverity::WARNING
));
} else {
$fileLocation = $this->getDownloadTarGzTargetPath($version);
$expectedChecksum = $coreRelease->getChecksum();
if (!file_exists($fileLocation)) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'',
'Downloaded TYPO3 CMS core not found',
ContextualFeedbackSeverity::ERROR
));
} else {
$actualChecksum = sha1_file($fileLocation);
if ($actualChecksum !== $expectedChecksum) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'The official TYPO3 CMS version system on https://get.typo3.org expects a sha1 checksum of '
. $expectedChecksum . ' from the content of the downloaded new TYPO3 CMS core version ' . $version . '.'
. ' The actual checksum is ' . $actualChecksum . '. The update is stopped. This may be a'
. ' failed download, an attack, or an issue with the typo3.org infrastructure.',
'New TYPO3 CMS core checksum mismatch',
ContextualFeedbackSeverity::ERROR
));
} else {
$this->messages->enqueue(new FlashMessage(
'',
'Checksum verified'
));
}
}
}
return $success;
}
/**
* Unpack a downloaded core
*
* @param CoreRelease $coreRelease A core release to unpack
* @return bool TRUE on success
*/
public function unpackVersion(CoreRelease $coreRelease)
{
$version = $coreRelease->getVersion();
$success = true;
if ($this->checkCoreFilesAvailable($version)) {
$this->messages->enqueue(new FlashMessage(
'',
'Unpacking TYPO3 CMS core files skipped',
ContextualFeedbackSeverity::NOTICE
));
} else {
$fileLocation = $this->downloadTargetPath . $version . '.tar.gz';
if (!@is_file($fileLocation)) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'',
'Downloaded TYPO3 CMS core not found',
ContextualFeedbackSeverity::ERROR
));
} elseif (@file_exists($this->downloadTargetPath . 'typo3_src-' . $version)) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'',
'Unpacked TYPO3 CMS core exists in download location: ' . PathUtility::stripPathSitePrefix($this->downloadTargetPath),
ContextualFeedbackSeverity::ERROR
));
} else {
$unpackCommand = 'tar xf ' . escapeshellarg($fileLocation) . ' -C ' . escapeshellarg($this->downloadTargetPath) . ' 2>&1';
exec($unpackCommand, $output, $errorCode);
if ($errorCode) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'',
'Unpacking TYPO3 CMS core not successful',
ContextualFeedbackSeverity::ERROR
));
} else {
$removePackedFileResult = unlink($fileLocation);
if (!$removePackedFileResult) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'',
'Removing packed TYPO3 CMS core not successful',
ContextualFeedbackSeverity::ERROR
));
} else {
$this->messages->enqueue(new FlashMessage(
'',
'Unpacking TYPO3 CMS core successful'
));
}
}
}
}
return $success;
}
/**
* Move an unpacked core to its final destination
*
* @param CoreRelease $coreRelease A core release to move
* @return bool TRUE on success
*/
public function moveVersion(CoreRelease $coreRelease)
{
$version = $coreRelease->getVersion();
$success = true;
if ($this->checkCoreFilesAvailable($version)) {
$this->messages->enqueue(new FlashMessage(
'',
'Moving TYPO3 CMS core files skipped',
ContextualFeedbackSeverity::NOTICE
));
} else {
$downloadedCoreLocation = $this->downloadTargetPath . 'typo3_src-' . $version;
$newCoreLocation = @realpath($this->symlinkToCoreFiles . '/../') . '/typo3_src-' . $version;
if (!@is_dir($downloadedCoreLocation)) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'',
'Unpacked TYPO3 CMS core not found',
ContextualFeedbackSeverity::ERROR
));
} else {
$moveResult = rename($downloadedCoreLocation, $newCoreLocation);
if (!$moveResult) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'',
'Moving TYPO3 CMS core to ' . $newCoreLocation . ' failed',
ContextualFeedbackSeverity::ERROR
));
} else {
$this->messages->enqueue(new FlashMessage(
'',
'Moved TYPO3 CMS core to final location'
));
}
}
}
return $success;
}
/**
* Activate a core version
*
* @param CoreRelease $coreRelease A core release to activate
* @return bool TRUE on success
*/
public function activateVersion(CoreRelease $coreRelease)
{
$newCoreLocation = @realpath($this->symlinkToCoreFiles . '/../') . '/typo3_src-' . $coreRelease->getVersion();
$success = true;
if (!is_dir($newCoreLocation)) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'',
'New TYPO3 CMS core not found',
ContextualFeedbackSeverity::ERROR
));
} elseif (!is_link($this->symlinkToCoreFiles)) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'',
'TYPO3 CMS core source directory (typo3_src) is not a link',
ContextualFeedbackSeverity::ERROR
));
} else {
$isCurrentCoreSymlinkAbsolute = PathUtility::isAbsolutePath((string)readlink($this->symlinkToCoreFiles));
$unlinkResult = unlink($this->symlinkToCoreFiles);
if (!$unlinkResult) {
$success = false;
$this->messages->enqueue(new FlashMessage(
'',
'Removing old symlink failed',
ContextualFeedbackSeverity::ERROR
));
} else {
if (!$isCurrentCoreSymlinkAbsolute) {
$newCoreLocation = $this->getRelativePath($newCoreLocation);
}
$symlinkResult = symlink($newCoreLocation, $this->symlinkToCoreFiles);
if ($symlinkResult) {
GeneralUtility::makeInstance(OpcodeCacheService::class)->clearAllActive();
} else {
$success = false;
$this->messages->enqueue(new FlashMessage(
'',
'Linking new TYPO3 CMS core failed',
ContextualFeedbackSeverity::ERROR
));
}
}
}
return $success;
}
/**
* Absolute path of downloaded .tar.gz
*
* @param string $version A version number
* @return string
*/
protected function getDownloadTarGzTargetPath($version)
{
return $this->downloadTargetPath . $version . '.tar.gz';
}
/**
* Get relative path to TYPO3 source directory from webroot
*
* @param string $absolutePath to TYPO3 source directory
* @return string relative path to TYPO3 source directory
*/
protected function getRelativePath($absolutePath)
{
$sourcePath = explode(DIRECTORY_SEPARATOR, Environment::getPublicPath());
$targetPath = explode(DIRECTORY_SEPARATOR, rtrim($absolutePath, DIRECTORY_SEPARATOR));
while (count($sourcePath) && count($targetPath) && $sourcePath[0] === $targetPath[0]) {
array_shift($sourcePath);
array_shift($targetPath);
}
return str_pad('', count($sourcePath) * 3, '..' . DIRECTORY_SEPARATOR) . implode(DIRECTORY_SEPARATOR, $targetPath);
}
/**
* Check if there is are already core files available
* at the download destination.
*
* @param string $version A version number
* @return bool true when core files are available
*/
protected function checkCoreFilesAvailable($version)
{
$newCoreLocation = @realpath($this->symlinkToCoreFiles . '/../') . '/typo3_src-' . $version;
return @is_dir($newCoreLocation);
}
}
+214
View File
@@ -0,0 +1,214 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service;
use TYPO3\CMS\Core\Information\Typo3Version;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Install\CoreVersion\CoreRelease;
use TYPO3\CMS\Install\CoreVersion\MaintenanceWindow;
use TYPO3\CMS\Install\CoreVersion\MajorRelease;
use TYPO3\CMS\Install\Service\Exception\RemoteFetchException;
/**
* Core version service
* @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
*/
class CoreVersionService
{
/**
* Base URI for TYPO3 Version REST api
*
* @var string
*/
protected $apiBaseUrl = 'https://get.typo3.org/api/v1/';
/**
* Development git checkout versions always end with '-dev'. They are
* not "released" as such and can not be updated.
*
* @return bool FALSE If some development version is installed
*/
public function isInstalledVersionAReleasedVersion(): bool
{
$version = $this->getInstalledVersion();
return substr($version, -4) !== '-dev';
}
/**
* Get current installed version number
*/
public function getInstalledVersion(): string
{
return (string)GeneralUtility::makeInstance(Typo3Version::class);
}
public function getMaintenanceWindow(): MaintenanceWindow
{
$url = 'major/' . $this->getInstalledMajorVersion();
$result = $this->fetchFromRemote($url);
return MaintenanceWindow::fromApiResponse($result);
}
/**
* @todo docblock
* @return array{community: string[], elts: string[]}
*/
public function getSupportedMajorReleases(): array
{
$url = 'major';
$result = $this->fetchFromRemote($url);
$majorReleases = [
'community' => [],
'elts' => [],
];
foreach ($result as $release) {
$majorRelease = MajorRelease::fromApiResponse($release);
$maintenanceWindow = $majorRelease->getMaintenanceWindow();
if ($maintenanceWindow->isSupportedByCommunity()) {
$group = 'community';
} elseif ($maintenanceWindow->isSupportedByElts()) {
$group = 'elts';
} else {
// Major version is unsupported
continue;
}
$majorReleases[$group][] = $majorRelease->getLts() ?? $majorRelease->getVersion();
}
return $majorReleases;
}
public function isPatchReleaseSuitableForUpdate(CoreRelease $coreRelease): bool
{
return version_compare($this->getInstalledVersion(), $coreRelease->getVersion()) === -1;
}
/**
* Returns TRUE if an upgrade from current version is security relevant
*
* @return bool TRUE if there is a pending security update
* @throws \TYPO3\CMS\Install\Service\Exception\RemoteFetchException
*/
public function isUpdateSecurityRelevant(CoreRelease $releaseToCheck): bool
{
$url = 'major/' . $this->getInstalledMajorVersion() . '/release';
$result = $this->fetchFromRemote($url);
$installedVersion = $this->getInstalledVersion();
foreach ($result as $release) {
$coreRelease = CoreRelease::fromApiResponse($release);
if ($coreRelease->isSecurityUpdate()
&& version_compare($installedVersion, $coreRelease->getVersion()) === -1 // installed version is lower than release
&& version_compare($releaseToCheck->getVersion(), $coreRelease->getVersion()) > -1 // release to check is equal or higher than release
) {
return true;
}
}
return false;
}
public function isCurrentInstalledVersionElts(): bool
{
$url = 'major/' . $this->getInstalledMajorVersion() . '/release';
$result = $this->fetchFromRemote($url);
$installedVersion = $this->getInstalledVersion();
foreach ($result as $release) {
if (version_compare($installedVersion, $release['version']) === 0) {
return $release['elts'] ?? false;
}
}
return false;
}
/**
* Youngest patch release
*
* @throws \TYPO3\CMS\Install\Service\Exception\RemoteFetchException
*/
public function getYoungestPatchRelease(): CoreRelease
{
$url = 'major/' . $this->getInstalledMajorVersion() . '/release/latest';
$result = $this->fetchFromRemote($url);
return CoreRelease::fromApiResponse($result);
}
public function getYoungestCommunityPatchRelease(): CoreRelease
{
$url = 'major/' . $this->getInstalledMajorVersion() . '/release';
$result = $this->fetchFromRemote($url);
// Make sure all releases are sorted by their version
$columns = array_column($result, 'version');
array_multisort($columns, SORT_NATURAL, $result);
// Remove any ELTS release
$releases = array_filter($result, static function (array $release) {
return ($release['elts'] ?? false) === false;
});
$latestRelease = end($releases);
return CoreRelease::fromApiResponse($latestRelease);
}
/**
* @throws \TYPO3\CMS\Install\Service\Exception\RemoteFetchException
*/
protected function fetchFromRemote(string $url): array
{
$url = $this->apiBaseUrl . $url;
$json = GeneralUtility::getUrl($url);
if (!$json) {
$this->throwFetchException($url);
}
return json_decode($json, true);
}
/**
* Get 'major version' from installed version of TYPO3, e.g., '7' from '7.3.0'
*
* @return string For example 7
*/
protected function getInstalledMajorVersion(): string
{
return (string)GeneralUtility::makeInstance(Typo3Version::class)->getMajorVersion();
}
/**
* Helper method to throw same exception in multiple places
*
* @throws \TYPO3\CMS\Install\Service\Exception\RemoteFetchException
*/
protected function throwFetchException(string $url): void
{
throw new RemoteFetchException(
'Fetching '
. $url
. ' failed. Maybe this instance can not connect to the remote system properly.',
1380897593
);
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Basic Service to check and create install tool files
* @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
*/
class EnableFileService
{
/**
* @var string file name of the ENABLE_INSTALL_TOOL file
*/
public const INSTALL_TOOL_ENABLE_FILE_PATH = 'ENABLE_INSTALL_TOOL';
/**
* @var string Relative path to FIRST_INSTALL file
*/
public const FIRST_INSTALL_FILE_PATH = 'FIRST_INSTALL';
/**
* @var int Maximum age of ENABLE_INSTALL_TOOL file before it gets removed (in seconds)
*/
public const INSTALL_TOOL_ENABLE_FILE_LIFETIME = 3600;
public static function isFirstInstallAllowed(): bool
{
$files = self::getFirstInstallFilePaths();
if (!empty($files)) {
return true;
}
return false;
}
/**
* Creates the INSTALL_TOOL_ENABLE file
*/
public static function createInstallToolEnableFile(): bool
{
$installEnableFilePath = self::getInstallToolEnableFilePath();
if (!is_file($installEnableFilePath)) {
GeneralUtility::mkdir_deep(dirname($installEnableFilePath));
$result = touch($installEnableFilePath);
} else {
$result = true;
self::extendInstallToolEnableFileLifetime();
}
GeneralUtility::fixPermissions($installEnableFilePath);
return $result;
}
/**
* Removes the INSTALL_TOOL_ENABLE file from all locations
*/
public static function removeInstallToolEnableFile(): bool
{
$result = false;
while (is_file(self::getInstallToolEnableFilePath())) {
$result = unlink(self::getInstallToolEnableFilePath());
}
return $result;
}
public static function removeFirstInstallFile(): bool
{
$result = true;
$files = self::getFirstInstallFilePaths();
foreach ($files as $file) {
// `getFirstInstallFilePaths()` returns list of existing files only and
// allows us to simply unlink them without superfluous additional checks.
$result = @unlink($file) && $result;
}
return $result;
}
/**
* Checks if the install tool file exists
*/
public static function installToolEnableFileExists(): bool
{
return @is_file(self::getInstallToolEnableFilePath());
}
/**
* Checks if the install tool file exists
*/
public static function checkInstallToolEnableFile(): bool
{
if (!self::installToolEnableFileExists()) {
return false;
}
if (!self::isInstallToolEnableFilePermanent()) {
if (self::installToolEnableFileLifetimeExpired()) {
self::removeInstallToolEnableFile();
return false;
}
self::extendInstallToolEnableFileLifetime();
}
return true;
}
/**
* Checks if the install tool file should be kept
*/
public static function isInstallToolEnableFilePermanent(): bool
{
if (self::installToolEnableFileExists()) {
$content = (string)@file_get_contents(self::getInstallToolEnableFilePath());
if (str_contains($content, 'KEEP_FILE')) {
return true;
}
}
return false;
}
/**
* Checks if the lifetime of the install tool file is expired
*/
public static function installToolEnableFileLifetimeExpired(): bool
{
if (time() - @filemtime(self::getInstallToolEnableFilePath()) > self::INSTALL_TOOL_ENABLE_FILE_LIFETIME) {
return true;
}
return false;
}
/**
* Updates the last modification of the ENABLE_INSTALL_TOOL file
*/
protected static function extendInstallToolEnableFileLifetime()
{
$enableFile = self::getInstallToolEnableFilePath();
// Extend the age of the ENABLE_INSTALL_TOOL file by one hour
if (is_file($enableFile)) {
$couldTouch = @touch($enableFile);
if (!$couldTouch) {
// If we can't remove the creation method will call us again.
if (self::removeInstallToolEnableFile()) {
self::createInstallToolEnableFile();
}
}
}
}
/**
* Returns a static directory path that is suitable to be presented to
* unauthenticated visitors, in order to circumvent "Full Path Disclosure" issues.
* This is just used for display purposes.
*/
public static function getStaticLocationForInstallToolEnableFileDirectory(): string
{
return Environment::isComposerMode() ? 'var/transient/' : 'typo3conf/';
}
public static function getBestLocationForInstallToolEnableFile(): string
{
return self::getTransientPath() . '/' . self::INSTALL_TOOL_ENABLE_FILE_PATH;
}
/**
* Based on composer or legacy mode, return a directory
* location where lock file can be stored.
*/
protected static function getTransientPath(): string
{
$possibleLocations = [
'composer' => Environment::getVarPath() . '/transient',
'legacy' => Environment::getConfigPath(),
];
return Environment::isComposerMode() ? $possibleLocations['composer'] : $possibleLocations['legacy'];
}
/**
* Returns the absolute path to the INSTALL_TOOL_ENABLE file
*/
protected static function getInstallToolEnableFilePath(): string
{
$possibleLocations = [
'default' => Environment::getVarPath() . '/transient/' . self::INSTALL_TOOL_ENABLE_FILE_PATH,
'permanent' => Environment::getConfigPath() . '/' . self::INSTALL_TOOL_ENABLE_FILE_PATH,
'legacy' => Environment::getLegacyConfigPath() . self::INSTALL_TOOL_ENABLE_FILE_PATH,
];
foreach ($possibleLocations as $location) {
if (@is_file($location)) {
return $location;
}
}
return self::getBestLocationForInstallToolEnableFile();
}
/**
* List of found `FIRST_INSTALL` files with different casings in public and project folder.
*
* @returns non-empty-string[]
*/
protected static function getFirstInstallFilePaths(): array
{
// Check in public path
$files = scandir(Environment::getPublicPath() . '/');
$files = is_array($files) ? $files : [];
$files = array_filter($files, static function ($file) {
return @is_file(Environment::getPublicPath() . '/' . $file) && preg_match('~^' . self::FIRST_INSTALL_FILE_PATH . '.*~i', $file);
});
$files = array_map(fn(string $file): string => Environment::getPublicPath() . '/' . $file, $files);
// Check in project path (only if different from public path)
if (Environment::getPublicPath() !== Environment::getProjectPath()) {
$projectFiles = scandir(Environment::getProjectPath() . '/');
$projectFiles = is_array($projectFiles) ? $projectFiles : [];
$projectFiles = array_filter($projectFiles, static function ($file) {
return @is_file(Environment::getProjectPath() . '/' . $file) && preg_match('~^' . self::FIRST_INSTALL_FILE_PATH . '.*~i', $file);
});
$projectFiles = array_map(fn(string $file): string => Environment::getProjectPath() . '/' . $file, $projectFiles);
$files = array_unique(array_merge($files, $projectFiles));
}
return $files;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service;
/**
* A service exception
*/
class Exception extends \TYPO3\CMS\Install\Exception {}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service\Exception;
use TYPO3\CMS\Install\Service\Exception;
/**
* An exception thrown during setup, when the config dir was not created, but config file must be written
*/
class ConfigurationDirectoryDoesNotExistException extends Exception {}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service\Exception;
use TYPO3\CMS\Install\Service\Exception;
/**
* An exception thrown during setup, when the config file should be written, but already exists
*/
class ConfigurationFileAlreadyExistsException extends Exception {}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service\Exception;
use TYPO3\CMS\Install\Service\Exception;
/**
* An exception thrown if version validation against official version matrix fails
*/
class CoreVersionServiceException extends Exception {}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service\Exception;
/**
* An exception thrown if fetching a resource from a remote server fails
*/
class RemoteFetchException extends CoreVersionServiceException {}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service\Exception;
use TYPO3\CMS\Install\Service\Exception;
/**
* An exception thrown if the silent template file updater changed file content
*/
class TemplateFileChangedException extends Exception {}
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service;
use Psr\Container\ContainerInterface;
use TYPO3\CMS\Core\Core\BootService;
/**
* @internal This is NOT an API class, it is for internal use in the install tool only.
*/
class LateBootService extends BootService
{
public function getContainer(bool $allowCaching = false): ContainerInterface
{
return parent::getContainer($allowCaching);
}
public function loadExtLocalconfDatabase(bool $resetContainer = true, bool $allowCaching = false): ContainerInterface
{
return parent::loadExtLocalconfDatabase($resetContainer, $allowCaching);
}
}
@@ -0,0 +1,334 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service;
use TYPO3\CMS\Core\Configuration\ConfigurationManager;
use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\Exception\MissingArrayPathException;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Service handling bulk read and write of LocalConfiguration values.
*
* Used by "Configure global settings" / "All configuration" view.
* @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
*/
class LocalConfigurationValueService
{
/**
* Get up configuration data. Prepares main TYPO3_CONF_VARS
* array to be displayed and merges is with the description file
*
* @return array Configuration data
*/
public function getCurrentConfigurationData(): array
{
$configurationManager = GeneralUtility::makeInstance(ConfigurationManager::class);
$localConfiguration = $configurationManager->getMergedLocalConfiguration();
$data = [];
$commentArray = $this->getDefaultConfigArrayComments();
foreach ($localConfiguration as $sectionName => $section) {
if (isset($commentArray[$sectionName])) {
$data[$sectionName]['description'] = $commentArray[$sectionName]['description'] ?? $sectionName;
$data[$sectionName]['items'] = $this->recursiveConfigurationFetching(
$section,
$GLOBALS['TYPO3_CONF_VARS'][$sectionName] ?? null,
$commentArray[$sectionName]
);
}
}
ksort($data);
return $data;
}
/**
* Because configuration entries can be at any sub-array level, we need
* to check entries recursively.
* Supported description types are:
* - `bool` boolean on/off toggles
* - `dropdown` dropdowns
* - `text` single-line text
* - `int` number inputs
* - `list` single-line text with comma-separated values
* - `multiline` multi-line text input
* - `password` password input
* - `mixed` mixed-types, which can behave either as "text" or an array (only via manually editing `settings.php`)
* - `container` a container for grouping multiple inputs
* - `phpClass` a string representing a PHP classname
* - `errors` a special dropdowns for PHP error mappings
* - `array` comma-separated values treated as a "list" (like an "array with numerical values")
* - `map` array keys+values (`$someArray['someKey' => 'someValue']`)
* - `element-list` numerical indexed array values (`$someArray[] = 'someValue'`)
*/
protected function recursiveConfigurationFetching(array $sections, array $sectionsFromCurrentConfiguration, array $descriptions, array $path = []): array
{
$data = [];
foreach ($sections as $key => $value) {
if (!isset($descriptions['items'][$key])) {
// @todo should we do something here?
continue;
}
$descriptionInfo = $descriptions['items'][$key];
$descriptionType = $descriptionInfo['type'];
$newPath = $path;
$newPath[] = $key;
if ($descriptionType === 'container') {
$valueFromCurrentConfiguration = $sectionsFromCurrentConfiguration[$key] ?? null;
$data = array_merge($data, $this->recursiveConfigurationFetching($value, $valueFromCurrentConfiguration, $descriptionInfo, $newPath));
} elseif (!preg_match('/[' . LF . CR . ']/', (string)(is_array($value) ? '' : $value)) || $descriptionType === 'multiline') {
$itemData = [];
$itemData['key'] = implode('/', $newPath);
$itemData['path'] = '[' . implode('][', $newPath) . ']';
$itemData['fieldType'] = $descriptionInfo['type'];
$itemData['description'] = $descriptionInfo['description'] ?? '';
$itemData['readonly'] = $descriptionInfo['readonly'] ?? false;
$itemData['allowedValues'] = $descriptionInfo['allowedValues'] ?? [];
$itemData['differentValueInCurrentConfiguration'] = (!isset($descriptionInfo['compareValuesWithCurrentConfiguration'])
|| $descriptionInfo['compareValuesWithCurrentConfiguration'])
&& isset($sectionsFromCurrentConfiguration[$key])
&& $value !== $sectionsFromCurrentConfiguration[$key];
switch ($descriptionType) {
case 'multiline':
$itemData['type'] = 'textarea';
$itemData['value'] = str_replace(['\' . LF . \'', '\' . LF . \''], [LF, LF], $value);
break;
case 'bool':
$itemData['type'] = 'checkbox';
$itemData['value'] = $value ? '1' : '0';
$itemData['checked'] = (bool)$value;
break;
case 'int':
$itemData['type'] = 'number';
$itemData['value'] = (int)$value;
break;
case 'map':
$itemData['type'] = 'map';
// Compatibility
$itemData['value'] = is_array($value) ? implode(',', $value) : (string)$value;
$itemData['values'] = is_array($value) ? $value : null;
$itemData['hideValue'] = true;
$itemData['arrayKey'] = $descriptionInfo['arrayKey'] ?? 'Key';
$itemData['arrayValue'] = $descriptionInfo['arrayValue'] ?? 'Value';
break;
case 'element-list':
// Same as above, but without special array key (just numerical index).
$itemData['type'] = 'element-list';
// Compatibility
$itemData['value'] = is_array($value) ? implode(',', $value) : (string)$value;
$itemData['values'] = is_array($value) ? $value : null;
$itemData['hideValue'] = true;
$itemData['arrayValue'] = $descriptionInfo['arrayValue'] ?? 'Value';
break;
case 'array':
$itemData['type'] = 'input';
// @todo The line below should be improved when the array handling is introduced in the global settings manager.
// @todo Also the types 'map' and 'element-list' above should be revisited then.
$itemData['value'] = is_array($value)
? implode(',', $value)
: (string)$value;
break;
// Check if the setting is a PHP error code, will trigger a view helper in fluid
case 'errors':
$itemData['type'] = 'input';
$itemData['value'] = $value;
$itemData['phpErrorCode'] = true;
break;
case 'password':
$itemData['type'] = 'password';
$itemData['value'] = $value;
$itemData['hideValue'] = true;
break;
default:
$itemData['type'] = 'input';
$itemData['value'] = $value;
}
$data[] = $itemData;
}
}
return $data;
}
/**
* Store changed values in LocalConfiguration
*
* @param array $valueList Nested array with key['key'] value
*/
public function updateLocalConfigurationValues(array $valueList): FlashMessageQueue
{
$messageQueue = new FlashMessageQueue('install');
$configurationPathValuePairs = [];
$commentArray = $this->getDefaultConfigArrayComments();
$configurationManager = GeneralUtility::makeInstance(ConfigurationManager::class);
foreach ($valueList as $path => $value) {
try {
$oldValue = $configurationManager->getConfigurationValueByPath($path);
} catch (MissingArrayPathException) {
$messageQueue->enqueue(new FlashMessage(
'Update rejected, the category of this setting does not exist',
$path,
ContextualFeedbackSeverity::ERROR
));
continue;
}
$pathParts = explode('/', $path);
$descriptionData = $commentArray[$pathParts[0]];
while ($part = next($pathParts)) {
if (!isset($descriptionData['items'][$part])) {
$messageQueue->enqueue(new FlashMessage(
'Update rejected, this setting is not writable',
$path,
ContextualFeedbackSeverity::ERROR
));
continue 2;
}
$descriptionData = $descriptionData['items'][$part];
}
$dataType = $descriptionData['type'];
if ($dataType === 'multiline') {
$value = str_replace(CR, '', $value);
$valueHasChanged = (string)$oldValue !== (string)$value;
} elseif ($dataType === 'bool') {
// When submitting settings in the Install Tool, values that default to "FALSE" or "TRUE"
// in EXT:core/Configuration/DefaultConfiguration.php will be sent as "0" resp. "1".
$value = $value === '1';
$valueHasChanged = (bool)$oldValue !== $value;
} elseif ($dataType === 'int') {
// Cast integer values to integers (but only for values that can not contain a string as well)
$value = (int)$value;
$valueHasChanged = (int)$oldValue !== $value;
} elseif ($dataType === 'map') {
// Validate array
if (!is_array($value)) {
$value = [];
}
$cleanedArray = [];
foreach ($value as $arrayKey => $arrayValue) {
if (!is_scalar($arrayValue)) {
// Sub-arrays and any non-scalar key or value type not supported
continue;
}
// Note: Actual values (unlike keys) are scrubbed later and need no slashing here.
// Restoring config values with slashes is a problem, so instead we use htmlentities() to escape
// single and double quotes, which keeps PHP namespace backslashes.
// @todo may need further inspection.
$cleanedArray[htmlentities((string)$arrayKey)] = (string)$arrayValue;
}
$value = $cleanedArray;
// Incoming array data is sorted, but the GUI may have a different sorting.
// To prevent the GUI from flagging the same configuration values as changed,
// when the configuration is written multiple times without a reload, the comparison
// here checks for actual differences in values, not order (as json_encode() would do).
// We need to compare array1 vs. array2 and array2 vs. array1 to both find differences
// in removed and added keys.
$valueHasChanged = (ArrayUtility::arrayDiffAssocRecursive($value, $oldValue) !== [] || ArrayUtility::arrayDiffAssocRecursive($oldValue, $value) !== []);
} elseif ($dataType === 'element-list') {
// Validate array
if (!is_array($value)) {
$value = [];
}
// Iterate list, throw away keys, start off zero-based.
$elementList = $value;
$value = [];
foreach ($elementList as $arrayValue) {
if (is_scalar($arrayValue)) {
// Sub-arrays and any non-scalar key or value type not supported
$value[] = $arrayValue;
}
}
$oldValueAsJson = json_encode($oldValue);
$valueHasChanged = $oldValueAsJson !== json_encode($elementList);
} elseif ($dataType === 'array') {
$oldValueAsString = is_array($oldValue)
? implode(',', $oldValue)
: (string)$oldValue;
$valueHasChanged = $oldValueAsString !== $value;
$value = GeneralUtility::trimExplode(',', $value, true);
} else {
$valueHasChanged = (string)$oldValue !== (string)$value;
}
$readonly = $descriptionData['readonly'] ?? false;
if ($readonly && $valueHasChanged) {
$messageQueue->enqueue(new FlashMessage(
'Update rejected, this setting is readonly',
$path,
ContextualFeedbackSeverity::ERROR
));
continue;
}
// Save if value changed
if ($valueHasChanged) {
$configurationPathValuePairs[$path] = $value;
if (is_bool($value)) {
$messageBody = 'New value = ' . ($value ? 'true' : 'false');
} elseif ($dataType === 'map') {
// "element-list" is covered by the 'is_array()' case.
$messageBody = 'New array value = ' . json_encode($value);
} elseif (empty($value)) {
$messageBody = 'New value = none';
} elseif (is_array($value)) {
$messageBody = "New value = ['" . implode("', '", $value) . "']";
} elseif ($dataType === 'password') {
$messageBody = 'New value is set';
} else {
$messageBody = 'New value = ' . $value;
}
$messageQueue->enqueue(new FlashMessage(
$messageBody,
$path
));
}
}
if ($messageQueue->count() > 0) {
$configurationManager->setLocalConfigurationValuesByPathValuePairs($configurationPathValuePairs);
}
return $messageQueue;
}
/**
* Read descriptions from description file
*/
protected function getDefaultConfigArrayComments(): array
{
$configurationManager = GeneralUtility::makeInstance(ConfigurationManager::class);
$fileName = $configurationManager->getDefaultConfigurationDescriptionFileLocation();
$fileLoader = GeneralUtility::makeInstance(YamlFileLoader::class);
return $fileLoader->load($fileName);
}
}
@@ -0,0 +1,280 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service\Session;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Security\BlockSerializationTrait;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Install\Service\Exception;
/**
* PHP session handling with "secure" session files (hashed session id)
* see http://www.php.net/manual/en/function.session-set-save-handler.php
*/
class FileSessionHandler implements \SessionHandlerInterface
{
use BlockSerializationTrait;
/**
* The path to our var/session/ folder (where we can write our sessions). Set in the
* constructor.
* Path where to store our session files in var/session/.
*/
private string $sessionPath;
/**
* time (minutes) to expire an unused session
*/
private int $expirationTimeInMinutes;
private HashService $hashService;
public function __construct(
int $expirationTimeInMinutes,
?string $sessionPath = null,
) {
$this->hashService = new HashService();
$this->sessionPath = rtrim($sessionPath ?? Environment::getVarPath() . '/session', '/') . '/';
$this->expirationTimeInMinutes = $expirationTimeInMinutes;
// Start our PHP session early so that hasSession() works
session_save_path($this->getSessionSavePath());
}
/**
* Returns the path where to store our session files
*
* @throws \TYPO3\CMS\Install\Exception
*/
private function getSessionSavePath(): string
{
if (empty($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'])) {
throw new \TYPO3\CMS\Install\Exception(
'No encryption key set to secure session',
1371243449
);
}
$sessionSavePath = $this->sessionPath . $this->hashService->hmac('session:' . $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'], self::class);
$this->ensureSessionSavePathExists($sessionSavePath);
return $sessionSavePath;
}
/**
* Returns the file where to store our session data
*/
private function getSessionFile(string $id): string
{
$sessionSavePath = $this->getSessionSavePath();
return $sessionSavePath . '/hash_' . $this->getSessionHash($id);
}
/**
* Open function. See @session_set_save_handler
*/
public function open(string $path, string $name): bool
{
return true;
}
/**
* Close function. See @session_set_save_handler
*/
public function close(): bool
{
return true;
}
/**
* Read session data. See @session_set_save_handler
*/
public function read(string $id): string|false
{
$sessionFile = $this->getSessionFile($id);
$content = '';
if (file_exists($sessionFile)) {
if ($fd = fopen($sessionFile, 'rb')) {
$lockres = flock($fd, LOCK_SH);
if ($lockres) {
$length = (int)filesize($sessionFile);
if ($length > 0) {
$content = (string)fread($fd, $length);
}
flock($fd, LOCK_UN);
}
fclose($fd);
}
}
// Do a "test write" of the session file after opening it. The real session data is written in
// __destruct() and we can not create a sane error message there anymore, so this test should fail
// before if final session file can not be written due to permission problems.
$this->write($id, $content);
return $content;
}
/**
* Write session data. See @session_set_save_handler
*/
public function write(string $id, string $data): bool
{
$sessionFile = $this->getSessionFile($id);
$result = false;
$changePermissions = !@is_file($sessionFile);
if ($fd = fopen($sessionFile, 'cb')) {
if (flock($fd, LOCK_EX)) {
ftruncate($fd, 0);
$res = fwrite($fd, $data);
if ($res !== false) {
fflush($fd);
$result = true;
}
flock($fd, LOCK_UN);
}
fclose($fd);
// Change the permissions only if the file has just been created
if ($changePermissions) {
GeneralUtility::fixPermissions($sessionFile);
}
}
if (!$result) {
throw new Exception(
'Session file not writable. Please check permission on '
. $this->sessionPath . ' and its subdirectories.',
1424355157
);
}
return true;
}
/**
* Destroys one session. See @session_set_save_handler
*/
public function destroy(string $id): bool
{
$sessionFile = $this->getSessionFile($id);
return @unlink($sessionFile);
}
/**
* Garbage collect session info. See @session_set_save_handler
*
* @param int $maxLifeTime The setting of session.gc_maxlifetime
*/
public function gc(int $maxLifeTime): int|false
{
$sessionSavePath = $this->getSessionSavePath();
$files = glob($sessionSavePath . '/hash_*');
if (!is_array($files)) {
return 0;
}
$deleted = 0;
foreach ($files as $filename) {
if (@filemtime($filename) + $this->expirationTimeInMinutes * 60 < time()) {
@unlink($filename);
$deleted++;
}
}
return $deleted;
}
/**
* Writes the session data at the end, to overcome a PHP APC bug.
*
* Writes the session data in a proper context that is not affected by the APC bug:
* http://pecl.php.net/bugs/bug.php?id=16721.
*
* This behaviour was introduced in #17511, where self::write() made use of GeneralUtility
* which due to the APC bug throws a "Fatal error: Class 'GeneralUtility' not found"
* (and the session data is not saved). Calling session_write_close() at this point
* seems to be the most easy solution, according to PHP author.
*/
public function __destruct()
{
session_write_close();
}
/**
* Returns the session ID of the running session.
*
* @return string|false the session ID
*/
public function getSessionId(): string|false
{
return session_id();
}
/**
* Returns a session hash, which can only be calculated by the server.
* Used to store our session files without exposing the session ID.
*
* @param string $sessionId An alternative session ID. Defaults to our current session ID
* @throws \TYPO3\CMS\Install\Exception
* @return string the session hash
*/
private function getSessionHash(string $sessionId = ''): string
{
if (empty($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'])) {
throw new \TYPO3\CMS\Install\Exception(
'No encryption key set to secure session',
1371243450
);
}
if (!$sessionId) {
$sessionId = (string)($this->getSessionId() ?: '');
}
return md5($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] . '|' . $sessionId);
}
/**
* Create directories for the session save path
* and throw an exception if that fails.
*
* @param string $sessionSavePath The absolute path to the session files
* @throws \TYPO3\CMS\Install\Exception
*/
private function ensureSessionSavePathExists(string $sessionSavePath): void
{
if (!is_dir($sessionSavePath)) {
try {
GeneralUtility::mkdir_deep($sessionSavePath);
} catch (\RuntimeException $exception) {
throw new \TYPO3\CMS\Install\Exception(
'Could not create session folder in ' . $this->sessionPath . '. Make sure it is writeable!',
1294587484
);
}
$htaccessContent = '
# Apache < 2.3
<IfModule !mod_authz_core.c>
Order allow,deny
Deny from all
Satisfy All
</IfModule>
# Apache ≥ 2.3
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
';
GeneralUtility::writeFile($sessionSavePath . '/.htaccess', $htaccessContent, true);
$indexContent = '<!DOCTYPE html>';
$indexContent .= '<html><head><title></title><meta http-equiv=Refresh Content="0; Url=../../"/>';
$indexContent .= '</head></html>';
GeneralUtility::writeFile($sessionSavePath . '/index.html', $indexContent, true);
}
}
}
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service\Session;
use TYPO3\CMS\Install\Exception;
final class RedisSessionHandler implements \SessionHandlerInterface
{
private \Redis $redis;
/**
* @param array{user?: string, pass?: string} $authentication
*/
public function __construct(
/**
* time (minutes) to expire an unused session
*/
private readonly int $expirationTimeInMinutes,
private readonly string $host = '127.0.0.1',
private readonly int $port = 6379,
private readonly int $database = 0,
private readonly array $authentication = [],
) {
$this->redis = new \Redis();
$this->redis->connect($this->host, $this->port);
if (!empty($this->authentication)) {
$this->redis->auth($this->authentication);
}
$this->redis->select($this->database);
}
public function close(): bool
{
return true;
}
/**
* @throws Exception
*/
public function destroy(string $id): bool
{
$sessionHash = $this->getSessionHash($id);
$this->redis->del($sessionHash);
return true;
}
public function gc(int $max_lifetime): int
{
// garbage collection is handled by Redis itself, so we do not need to do anything here
return 0;
}
public function open(string $path, string $name): bool
{
return true;
}
/**
* @throws Exception
*/
public function read(string $id): string
{
$sessionHash = $this->getSessionHash($id);
$data = $this->redis->get($sessionHash);
// return empty string here to not use default php session handler behavior, because that will lead to an error
return $data === false ? '' : $data;
}
/**
* @throws Exception
*/
public function write(string $id, string $data): bool
{
$sessionHash = $this->getSessionHash($id);
return $this->redis->setex($sessionHash, $this->expirationTimeInMinutes * 60, $data);
}
/**
* Returns a session hash, which can only be calculated by the server.
* Used to store our session files without exposing the session ID.
*
* @param string $sessionId An alternative session ID. Defaults to our current session ID
* @throws \TYPO3\CMS\Install\Exception
*/
private function getSessionHash(string $sessionId = ''): string
{
if (empty($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'])) {
throw new \TYPO3\CMS\Install\Exception(
'No encryption key set to secure session',
1751729886
);
}
if (!$sessionId) {
$sessionId = (string)($this->getSessionId() ?: '');
}
return md5($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] . '|' . $sessionId);
}
/**
* Returns the session ID of the running session.
*/
public function getSessionId(): string|false
{
return session_id();
}
}
+460
View File
@@ -0,0 +1,460 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Cookie;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\Http\ServerRequestFactory;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Security\BlockSerializationTrait;
use TYPO3\CMS\Core\Session\Backend\HashableSessionBackendInterface;
use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
use TYPO3\CMS\Core\Session\SessionManager;
use TYPO3\CMS\Core\Session\UserSession;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Install\Exception;
use TYPO3\CMS\Install\Service\Session\FileSessionHandler;
/**
* Secure session handling for the install tool.
*
* @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
*/
class SessionService implements SingletonInterface
{
use BlockSerializationTrait;
/**
* the cookie to store the session ID of the install tool
*/
private string $cookieName = 'Typo3InstallTool';
/**
* time (minutes) to expire an unused session
*/
private int $expireTimeInMinutes = 15;
/**
* time (minutes) to generate a new session id for our current session
*/
private int $regenerateSessionIdTime = 5;
public function __construct(
protected readonly LateBootService $lateBootService,
protected readonly LoggerInterface $logger,
) {}
public function installSessionHandler(?ServerRequestInterface $request): void
{
// Register our "save" session handler
$sessionHandlerClass = $GLOBALS['TYPO3_CONF_VARS']['BE']['installToolSessionHandler']['className'] ?? FileSessionHandler::class;
$options = $GLOBALS['TYPO3_CONF_VARS']['BE']['installToolSessionHandler']['options'] ?? [];
$options['expirationTimeInMinutes'] = $this->expireTimeInMinutes;
try {
$sessionHandler = new $sessionHandlerClass(...$options);
} catch (\Throwable $throwable) {
$this->logger->error('Session handler is not configured properly: ' . $throwable->getMessage());
// Regardless of ANY misconfiguration, we expect the session handler - like the whole install tool - to work
// at ANY time. For this reason, any PHP error or misconfiguration fails silently to the FileSessionHandler.
$sessionHandler = $this->getDefaultSessionHandler();
}
$request = $request ?? ServerRequestFactory::fromGlobals();
$normalizedParams = $request->getAttribute('normalizedParams') ?? NormalizedParams::createFromRequest($request);
session_set_save_handler($sessionHandler);
session_name($this->cookieName);
ini_set('session.cookie_secure', $normalizedParams->isHttps() ? 'On' : 'Off');
ini_set('session.cookie_httponly', 'On');
ini_set('session.cookie_samesite', Cookie::SAMESITE_STRICT);
ini_set('session.cookie_path', $normalizedParams->getSitePath());
// Always call the garbage collector to clean up stale session files
ini_set('session.gc_probability', (string)100);
ini_set('session.gc_divisor', (string)100);
ini_set('session.gc_maxlifetime', (string)($this->expireTimeInMinutes * 2 * 60));
if ($this->isSessionAutoStartEnabled()) {
$sessionCreationError = 'Error: session.auto-start is enabled.<br />';
$sessionCreationError .= 'The PHP option session.auto-start is enabled. Disable this option in php.ini or .htaccess:<br />';
$sessionCreationError .= '<pre>php_value session.auto_start Off</pre>';
throw new Exception($sessionCreationError, 1294587485);
}
if (session_status() === PHP_SESSION_ACTIVE) {
$sessionCreationError = 'Session already started by session_start().<br />';
$sessionCreationError .= 'Make sure no installed extension is starting a session in its ext_localconf.php.';
throw new Exception($sessionCreationError, 1294587486);
}
}
protected function getDefaultSessionHandler(): \SessionHandlerInterface
{
return new FileSessionHandler($this->expireTimeInMinutes);
}
public function initializeSession()
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
session_start();
}
/**
* Starts a new session
*
* @return string|false The session ID
*/
public function startSession()
{
$this->initializeSession();
// check if session is already active
if ($_SESSION['active'] ?? false) {
return session_id();
}
$_SESSION['active'] = true;
// Be sure to use our own session id, so create a new one
return $this->renewSession();
}
/**
* Destroys a session
*/
public function destroySession(?ServerRequestInterface $request): void
{
$request = $request ?? ServerRequestFactory::fromGlobals();
if ($this->hasSessionCookie($request)) {
$normalizedParams = $request->getAttribute('normalizedParams') ?? NormalizedParams::createFromRequest($request);
$this->initializeSession();
$_SESSION = [];
$params = session_get_cookie_params();
$cookie = Cookie::create(($sessionName = session_name()) !== false ? $sessionName : $this->cookieName)
->withValue('0')
->withPath($params['path'])
->withDomain($params['domain'])
->withSecure($params['samesite'] === Cookie::SAMESITE_NONE || $normalizedParams->isHttps())
->withHttpOnly($params['httponly'])
->withSameSite($params['samesite']);
header('Set-Cookie: ' . $cookie);
session_destroy();
}
}
/**
* Reset session. Sets _SESSION to empty array.
*/
public function resetSession()
{
$this->initializeSession();
$_SESSION = [];
$_SESSION['active'] = false;
}
/**
* Generates a new session ID and sends it to the client.
*
* @return string|false the new session ID
*/
private function renewSession()
{
// we do not have parallel ajax requests, so we can safely remove the old session data
session_regenerate_id(true);
return session_id();
}
/**
* Checks whether is session cookie is set
*/
public function hasSessionCookie(ServerRequestInterface $request): bool
{
return isset($request->getCookieParams()[$this->cookieName]);
}
/**
* Marks this session as an "authorized" one (login successful).
* Should only be called if:
* a) we have a valid session running
* b) the "password" or some other authorization mechanism really matched
*/
public function setAuthorized()
{
$_SESSION['authorized'] = true;
$_SESSION['lastSessionId'] = time();
$_SESSION['tstamp'] = time();
$_SESSION['expires'] = time() + $this->expireTimeInMinutes * 60;
// Renew the session id to avoid session fixation
$this->renewSession();
}
/**
* Marks this session as an "authorized by backend user" one.
* This is called by BackendModuleController from backend context.
*
* @param UserSession $userSession session of the current backend user
*/
public function setAuthorizedBackendSession(UserSession $userSession, SessionBackendInterface $sessionBackend)
{
$nonce = bin2hex(random_bytes(20));
// use hash mechanism of session backend, or pass plain value through generic hmac
$sessionHmac = $sessionBackend instanceof HashableSessionBackendInterface
? $sessionBackend->hash($userSession->getIdentifier())
: hash_hmac('sha256', $userSession->getIdentifier(), $nonce);
$_SESSION['authorized'] = true;
$_SESSION['lastSessionId'] = time();
$_SESSION['tstamp'] = time();
$_SESSION['expires'] = time() + $this->expireTimeInMinutes * 60;
$_SESSION['isBackendSession'] = true;
$_SESSION['backendUserSession'] = [
'nonce' => $nonce,
'userId' => $userSession->getUserId(),
'hmac' => $sessionHmac,
];
// Renew the session id to avoid session fixation
$this->renewSession();
}
/**
* Check if we have an already authorized session
*
* @return bool TRUE if this session has been authorized before (by a correct password)
*/
public function isAuthorized(ServerRequestInterface $request): bool
{
if (!$this->hasSessionCookie($request)) {
return false;
}
$this->initializeSession();
if (empty($_SESSION['authorized'])) {
return false;
}
return !$this->isExpired($request);
}
/**
* Check if we have an authorized session from a system maintainer
*
* @return bool TRUE if this session has been authorized before and initialized by a backend system maintainer
*/
public function isAuthorizedBackendUserSession(ServerRequestInterface $request): bool
{
if (!$this->hasSessionCookie($request)) {
return false;
}
$this->initializeSession();
if (empty($_SESSION['authorized']) || empty($_SESSION['isBackendSession'])) {
return false;
}
return !$this->isExpired($request);
}
/**
* Evaluates whether the backend user that initiated this admin tool session,
* has an active role (is still admin & system maintainer) and has an active backend user interface session.
*
* @return bool whether the backend user has an active role and backend user interface session
*/
public function hasActiveBackendUserRoleAndSession(): bool
{
$container = $this->lateBootService->getContainer(
// Allow DI caching because this request was forwarded from a backend session,
// and therefore failsafe requirements do not apply
true
);
// Unset internal container instance in order for later services
// to be able to bootstrap a fresh container
$this->lateBootService->unsetInternalContainerInstance();
// @see \TYPO3\CMS\Install\Controller\BackendModuleController::setAuthorizedAndRedirect()
$backendUserSession = $this->getBackendUserSession();
$backendUserRecord = $this->getBackendUserRecord($container, $backendUserSession['userId']);
if ($backendUserRecord === null || empty($backendUserRecord['uid'])) {
return false;
}
$isAdmin = (($backendUserRecord['admin'] ?? 0) & 1) === 1;
$systemMaintainers = array_map('intval', $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemMaintainers'] ?? []);
// in case no system maintainers are configured, all admin users are considered to be system maintainers
$isSystemMaintainer = empty($systemMaintainers) || in_array((int)$backendUserRecord['uid'], $systemMaintainers, true);
// in development context, all admin users are considered to be system maintainers
$hasDevelopmentContext = Environment::getContext()->isDevelopment();
// stop here, in case the current admin tool session does not belong to a backend user having admin & maintainer privileges
if (!$isAdmin || !$hasDevelopmentContext && !$isSystemMaintainer) {
return false;
}
$sessionBackend = $container->get(SessionManager::class)->getSessionBackend('BE');
foreach ($sessionBackend->getAll() as $sessionRecord) {
$sessionUserId = (int)($sessionRecord['ses_userid'] ?? 0);
// skip, in case backend user id does not match
if ($backendUserSession['userId'] !== $sessionUserId) {
continue;
}
$sessionId = (string)($sessionRecord['ses_id'] ?? '');
// use persisted hashed `ses_id` directly, or pass through hmac for plain values
$sessionHmac = $sessionBackend instanceof HashableSessionBackendInterface
? $sessionId
: hash_hmac('sha256', $sessionId, $backendUserSession['nonce']);
// skip, in case backend user session id does not match
if ($backendUserSession['hmac'] !== $sessionHmac) {
continue;
}
// backend user id and session id matched correctly
return true;
}
return false;
}
/**
* Check if our session is expired.
* Useful only right after a FALSE "isAuthorized" to see if this is the
* reason for not being authorized anymore.
*
* @return bool TRUE if an authorized session exists, but is expired
*/
public function isExpired(ServerRequestInterface $request)
{
if (!$this->hasSessionCookie($request)) {
// Session never existed, means it is not "expired"
return false;
}
$this->initializeSession();
if (empty($_SESSION['authorized'])) {
// Session never authorized, means it is not "expired"
return false;
}
return $_SESSION['expires'] <= time();
}
/**
* Refreshes our session information, rising the expire time.
* Also generates a new session ID every 5 minutes to minimize the risk of
* session hijacking.
*/
public function refreshSession()
{
$_SESSION['tstamp'] = time();
$_SESSION['expires'] = time() + $this->expireTimeInMinutes * 60;
if (time() > $_SESSION['lastSessionId'] + $this->regenerateSessionIdTime * 60) {
// Renew our session ID
$_SESSION['lastSessionId'] = time();
$this->renewSession();
}
}
/**
* Add a message to "Flash" message storage.
*
* @param FlashMessage $message A message to add
*/
public function addMessage(FlashMessage $message)
{
if (!is_array($_SESSION['messages'])) {
$_SESSION['messages'] = [];
}
$_SESSION['messages'][] = $message;
}
/**
* Return stored session messages and flush.
*
* @return FlashMessage[] Messages
*/
public function getMessagesAndFlush()
{
$messages = [];
if (is_array($_SESSION['messages'])) {
$messages = $_SESSION['messages'];
}
$_SESSION['messages'] = [];
return $messages;
}
/**
* @return array{userId: int, nonce: string, hmac: string} backend user session references
*/
public function getBackendUserSession(): array
{
if (empty($_SESSION['backendUserSession'])) {
throw new Exception(
'The backend user session is only available if invoked via the backend user interface.',
1624879295
);
}
return $_SESSION['backendUserSession'];
}
/**
* Check if php session.auto_start is enabled
*
* @return bool TRUE if session.auto_start is enabled, FALSE if disabled
*/
protected function isSessionAutoStartEnabled()
{
return $this->getIniValueBoolean('session.auto_start');
}
/**
* Cast an on/off php ini value to boolean
*
* @param string $configOption
* @return bool TRUE if the given option is enabled, FALSE if disabled
*/
protected function getIniValueBoolean($configOption)
{
return filter_var(
ini_get($configOption),
FILTER_VALIDATE_BOOLEAN,
[FILTER_REQUIRE_SCALAR, FILTER_NULL_ON_FAILURE]
);
}
/**
* Fetching a user record with uid=$uid.
* Functionally similar to TYPO3\CMS\Core\Authentication\BackendUserAuthentication::setBeUserByUid().
*
* @param int $uid The UID of the backend user
* @return array<string, int>|null The backend user record or NULL
*/
protected function getBackendUserRecord(ContainerInterface $container, int $uid): ?array
{
$accessTimeStamp = (int)$GLOBALS['SIM_ACCESS_TIME'];
$queryBuilder = $container->get(ConnectionPool::class)->getQueryBuilderForTable('be_users');
$queryBuilder->select('uid', 'admin')
->from('be_users')
->where(
$queryBuilder->expr()->and(
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)),
// The admin tool intentionally does not load TCA schema at this time,
// therefore database restrictions applied manually
$queryBuilder->expr()->eq('pid', 0),
$queryBuilder->expr()->eq('deleted', 0),
$queryBuilder->expr()->eq('disable', 0),
$queryBuilder->expr()->lte('starttime', $accessTimeStamp),
$queryBuilder->expr()->or(
$queryBuilder->expr()->eq('endtime', 0),
$queryBuilder->expr()->gt('endtime', $accessTimeStamp),
)
)
);
$result = $queryBuilder->executeQuery()->fetchAssociative();
return is_array($result) ? $result : null;
}
}
+728
View File
@@ -0,0 +1,728 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service;
use Doctrine\DBAL\Connection as DoctrineConnection;
use Doctrine\DBAL\DriverManager;
use Doctrine\DBAL\Exception as DBALException;
use Doctrine\DBAL\Exception\ConnectionException;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Configuration\ConfigurationManager;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Crypto\Random;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Platform\PlatformInformation;
use TYPO3\CMS\Core\Database\Schema\SchemaMigrator;
use TYPO3\CMS\Core\Database\Schema\SqlReader;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
use TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyAction;
use TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyValidator;
use TYPO3\CMS\Core\PasswordPolicy\Validator\Dto\ContextData;
use TYPO3\CMS\Core\Registry;
use TYPO3\CMS\Core\Service\UpgradeWizardsService;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Upgrades\DatabaseRowsUpdateWizard;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Install\Configuration\Exception;
use TYPO3\CMS\Install\Database\PermissionsCheck;
use TYPO3\CMS\Install\SystemEnvironment\DatabaseCheck;
/**
* Service class helping to manage database related settings and operations required to set up TYPO3
* @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
*
* @phpstan-import-type Params from DriverManager
*/
#[Autoconfigure(public: true)]
class SetupDatabaseService
{
protected array $validDrivers = [
'mysqli',
'pdo_mysql',
'pdo_pgsql',
'pdo_sqlite',
];
public function __construct(
private readonly LateBootService $lateBootService,
private readonly ConfigurationManager $configurationManager,
private readonly PermissionsCheck $databasePermissionsCheck,
private readonly Registry $registry,
private readonly ConnectionPool $connectionPool,
) {}
/**
* @param array $values
* @return array
*/
public function setDefaultConnectionSettings(array $values): array
{
$messages = [];
if (($values['availableSet'] ?? '') === 'configurationFromEnvironment') {
$defaultConnectionSettings = $this->getDatabaseConfigurationFromEnvironment();
} else {
$defaultConnectionSettings = [];
if (isset($values['driver'])) {
if (in_array($values['driver'], $this->validDrivers, true)) {
$defaultConnectionSettings['driver'] = $values['driver'];
} else {
$messages[] = new FlashMessage(
'Given driver must be one of ' . implode(', ', $this->validDrivers),
'Database driver unknown',
ContextualFeedbackSeverity::ERROR
);
}
}
if (isset($values['username'])) {
$value = $values['username'];
if (strlen($value) <= 50) {
$defaultConnectionSettings['user'] = $value;
} else {
$messages[] = new FlashMessage(
'Given username must be shorter than fifty characters.',
'Database username not valid',
ContextualFeedbackSeverity::ERROR
);
}
}
if (isset($values['password'])) {
$defaultConnectionSettings['password'] = $values['password'];
}
if (isset($values['host'])) {
$value = $values['host'];
if ($this->isValidDbHost($value)) {
$defaultConnectionSettings['host'] = $value;
} else {
$messages[] = new FlashMessage(
'Given host is not alphanumeric (a-z, A-Z, 0-9 or _-.:) or longer than 255 characters.',
'Database host not valid',
ContextualFeedbackSeverity::ERROR
);
}
}
if (isset($values['port']) && $values['host'] !== 'localhost') {
$value = (int)$values['port'];
if ($this->isValidDbPort($value)) {
$defaultConnectionSettings['port'] = (int)$value;
} else {
$messages[] = new FlashMessage(
'Given port is not numeric or within range 1 to 65535.',
'Database port not valid',
ContextualFeedbackSeverity::ERROR
);
}
}
if (isset($values['socket']) && $values['socket'] !== '') {
if (@file_exists($values['socket'])) {
$defaultConnectionSettings['unix_socket'] = $values['socket'];
} else {
$messages[] = new FlashMessage(
'Given socket location does not exist on server.',
'Socket does not exist',
ContextualFeedbackSeverity::ERROR
);
}
}
if (isset($values['database'])) {
$value = $values['database'];
if ($this->isValidDbName($value)) {
$defaultConnectionSettings['dbname'] = $value;
} else {
$messages[] = new FlashMessage(
'Given database name must be shorter than fifty characters.',
'Database name not valid',
ContextualFeedbackSeverity::ERROR
);
}
}
// For sqlite a db path is automatically calculated
if (isset($values['driver']) && $values['driver'] === 'pdo_sqlite') {
$dbFilename = '/cms-' . (new Random())->generateRandomHexString(8) . '.sqlite';
// If the "var/" folder exists outside of document root, put it into "var/sqlite/"
// Otherwise simply into "typo3conf/"
if (Environment::getProjectPath() !== Environment::getPublicPath()) {
GeneralUtility::mkdir_deep(Environment::getVarPath() . '/sqlite');
$defaultConnectionSettings['path'] = Environment::getVarPath() . '/sqlite' . $dbFilename;
} else {
$defaultConnectionSettings['path'] = Environment::getConfigPath() . $dbFilename;
}
}
// Note hard setting default charset and defaultTableOptions here does not take `additional.php`
// (existing) configuration into account. This is not an issue for the `setup` cli command.
// The difference can be detected for example when the local development environment tool `ddev` is
// used. See class method `getDriverOptions()`
$defaultConnectionSettings = $this->setDefaultConnectionCharsetAndCollation($defaultConnectionSettings);
}
$success = false;
if (!empty($defaultConnectionSettings)) {
// Test connection settings and write to config if connect is successful
try {
$connectionParams = $defaultConnectionSettings;
$connectionParams['wrapperClass'] = DoctrineConnection::class;
$connection = DriverManager::getConnection($connectionParams);
if ($connection->getNativeConnection() !== null) {
$connection->executeQuery($connection->getDatabasePlatform()->getDummySelectSQL());
$success = true;
}
} catch (DBALException $e) {
$messages[] = new FlashMessage(
'Connecting to the database with given settings failed: ' . $e->getMessage(),
'Database connect not successful',
ContextualFeedbackSeverity::ERROR
);
}
$localConfigurationPathValuePairs = [];
foreach ($defaultConnectionSettings as $settingsName => $value) {
$localConfigurationPathValuePairs['DB/Connections/Default/' . $settingsName] = $value;
}
// Remove full default connection array
$this->configurationManager->removeLocalConfigurationKeysByPath(['DB/Connections/Default']);
// Write new values
$this->configurationManager->setLocalConfigurationValuesByPathValuePairs($localConfigurationPathValuePairs);
}
return [$success, $messages];
}
/**
* Try to fetch db credentials from a .env file and see if connect works
*
* @return array Empty array if no file is found or connect is not successful, else working credentials
*/
public function getDatabaseConfigurationFromEnvironment(): array
{
/** @var Params $envCredentials */
$envCredentials = [];
foreach (['driver', 'host', 'user', 'password', 'port', 'dbname', 'unix_socket'] as $value) {
$envVar = 'TYPO3_INSTALL_DB_' . strtoupper($value);
if (getenv($envVar) !== false) {
$envCredentials[$value] = getenv($envVar);
if ($value === 'port') {
$envCredentials[$value] = (int)$envCredentials[$value];
}
}
}
if (!empty($envCredentials)) {
$connectionParams = $envCredentials;
$connectionParams['wrapperClass'] = Connection::class;
$connectionParams = $this->setDefaultConnectionCharsetAndCollation($connectionParams);
try {
$connection = DriverManager::getConnection($connectionParams);
if ($connection->getNativeConnection() !== null) {
$connection->executeQuery($connection->getDatabasePlatform()->getDummySelectSQL());
return $connectionParams;
}
} catch (DBALException $e) {
return [];
}
}
return [];
}
public function isValidDbHost(string $name): bool
{
return preg_match('/^[a-zA-Z0-9_\\.-]+(:.+)?$/', $name) && strlen($name) <= 255;
}
public function isValidDbPort(int $number): bool
{
return preg_match('/^[0-9]+(:.+)?$/', (string)$number) && $number > 0 && $number <= 65535;
}
public function isValidDbName(string $name): bool
{
return strlen($name) <= 50;
}
public function getBackendUserPasswordValidationErrors(string $password): array
{
$passwordPolicy = $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordPolicy'] ?? 'default';
$passwordPolicyValidator = GeneralUtility::makeInstance(
PasswordPolicyValidator::class,
PasswordPolicyAction::NEW_USER_PASSWORD,
is_string($passwordPolicy) ? $passwordPolicy : ''
);
$contextData = new ContextData();
$passwordPolicyValidator->isValidPassword($password, $contextData);
return $passwordPolicyValidator->getValidationErrors();
}
/**
* Returns list of available databases (with access-check based on username/password)
*
* @return array List of available databases
* @throws DBALException
*/
public function getDatabaseList(): array
{
$connectionParams = $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME];
unset($connectionParams['dbname']);
// Establishing the connection using the Doctrine DriverManager directly
// as we need a connection without selecting a database right away. Otherwise
// an invalid database name would lead to exceptions which would prevent
// changing the currently configured database.
$connection = DriverManager::getConnection($connectionParams);
$databaseArray = $connection->createSchemaManager()->listDatabases();
$connection->close();
// Remove organizational tables from database list
$reservedDatabaseNames = ['mysql', 'information_schema', 'performance_schema'];
$allPossibleDatabases = array_diff($databaseArray, $reservedDatabaseNames);
// In first installation we show all databases but disable not empty ones (with tables)
$databases = [];
foreach ($allPossibleDatabases as $databaseName) {
// Reestablishing the connection for each database since there is no
// portable way to switch databases on the same Doctrine connection.
// Directly using the Doctrine DriverManager here to avoid messing with
// the $GLOBALS database configuration array.
try {
$connectionParams['dbname'] = $databaseName;
$connection = DriverManager::getConnection($connectionParams);
$databases[] = [
'name' => $databaseName,
'tables' => count($connection->createSchemaManager()->listTableNames()),
'readonly' => false,
];
$connection->close();
} catch (ConnectionException $exception) {
$databases[] = [
'name' => $databaseName,
'tables' => 0,
'readonly' => true,
];
// we ignore a connection exception here.
// if this happens here, the show tables was successful
// but the connection failed because of missing permissions.
}
}
return $databases;
}
public function checkDatabaseSelect(): bool
{
$success = false;
if ((string)($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['dbname'] ?? '') !== ''
|| (string)($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['path'] ?? '') !== ''
) {
try {
$connection = $this->connectionPool
->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
if ($connection->getNativeConnection() !== null) {
$connection->executeQuery($connection->getDatabasePlatform()->getDummySelectSQL());
$success = true;
}
} catch (DBALException $e) {
}
}
return $success;
}
/**
* @param string $name
* @throws DBALException
*/
public function createDatabase(string $name): void
{
$platform = $this->connectionPool
->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME)
->getDatabasePlatform();
$connection = $this->connectionPool
->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
$connection->executeStatement(
PlatformInformation::getDatabaseCreateStatementWithCharset(
$platform,
$connection->quoteIdentifier($name)
)
);
$this->configurationManager
->setLocalConfigurationValueByPath('DB/Connections/Default/dbname', $name);
}
/**
* Test connection with given credentials and return exception message if exception thrown
*/
public function isDatabaseConnectSuccessful(): bool
{
try {
$connection = $this->connectionPool
->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
if ($connection->getNativeConnection() !== null) {
$connection->executeQuery($connection->getDatabasePlatform()->getDummySelectSQL());
return true;
}
} catch (DBALException $e) {
}
return false;
}
/**
* Check system/settings.php for required database settings:
* - 'username' and 'password' are mandatory, but may be empty
* - if 'driver' is pdo_sqlite and 'path' is set, its ok, too
*
* @return bool TRUE if required settings are present
*/
public function isDatabaseConfigurationComplete(): bool
{
$configurationComplete = true;
if (!isset($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['user'])) {
$configurationComplete = false;
}
if (!isset($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['password'])) {
$configurationComplete = false;
}
if (isset($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['driver'])
&& $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['driver'] === 'pdo_sqlite'
&& !empty($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['path'])
) {
$configurationComplete = true;
}
return $configurationComplete;
}
/**
* Returns configured socket, if set.
*/
public function getDatabaseConfiguredMysqliSocket(): string
{
return $this->getDefaultSocketFor('mysqli.default_socket');
}
/**
* Returns configured socket, if set.
*/
public function getDatabaseConfiguredPdoMysqlSocket(): string
{
return $this->getDefaultSocketFor('pdo_mysql.default_socket');
}
/**
* Returns configured socket, if set.
*/
private function getDefaultSocketFor(string $phpIniSetting): string
{
$socket = $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['unix_socket'] ?? '';
if ($socket === '') {
// If no configured socket, use default php socket
$defaultSocket = (string)ini_get($phpIniSetting);
if ($defaultSocket !== '') {
$socket = $defaultSocket;
}
}
return $socket;
}
/**
* Creates a new database on the default connection
*
* @param string $dbName name of database
*/
public function createNewDatabase(string $dbName): FlashMessage
{
try {
$this->createDatabase($dbName);
} catch (DBALException $e) {
return new FlashMessage(
'Database with name "' . $dbName . '" could not be created.'
. ' Either your database name contains a reserved keyword or your database'
. ' user does not have sufficient permissions to create it or the database already exists.'
. ' Please choose an existing (empty) database, choose another name or contact administration.',
'Unable to create database',
ContextualFeedbackSeverity::ERROR
);
}
return new FlashMessage(
'',
'Database created'
);
}
/**
* Checks whether an existing database on the default connection
* can be used for a TYPO3 installation. The database name is only
* persisted to the local configuration if the database is empty.
*
* @param string $dbName name of the database
*/
public function checkExistingDatabase(string $dbName): FlashMessage
{
$result = new FlashMessage('');
$localConfigurationPathValuePairs = [];
$GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['dbname'] = $dbName;
try {
$connection = $this->connectionPool
->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
if (!empty($connection->createSchemaManager()->listTableNames())) {
$result = new FlashMessage(
sprintf('Cannot use database "%s"', $dbName)
. ', because it already contains tables. Please select a different database or choose to create one!',
'Selected database is not empty!',
ContextualFeedbackSeverity::ERROR
);
}
} catch (\Exception $e) {
$result = new FlashMessage(
sprintf('Could not connect to database "%s"', $dbName)
. '! Make sure it really exists and your database user has the permissions to select it!',
'Could not connect to selected database!',
ContextualFeedbackSeverity::ERROR
);
}
if ($result->getSeverity() === ContextualFeedbackSeverity::OK) {
$localConfigurationPathValuePairs['DB/Connections/Default/dbname'] = $dbName;
$this->configurationManager->setLocalConfigurationValuesByPathValuePairs($localConfigurationPathValuePairs);
}
return $result;
}
/**
* Create tables and import static rows
*
* @return FlashMessage[]
*/
public function importDatabaseData(): array
{
// Will load ext_localconf. This is pretty safe here since we are in first install
// (database empty), so it is very likely that no extension is loaded that could
// trigger a fatal at this point.
$container = $this->lateBootService->loadExtLocalconfDatabase();
$sqlReader = $container->get(SqlReader::class);
$sqlCode = $sqlReader->getTablesDefinitionString(true);
$createTableStatements = $sqlReader->getCreateTableStatementArray($sqlCode);
$schemaMigrator = $container->get(SchemaMigrator::class);
$results = $schemaMigrator->install($createTableStatements);
// Only keep statements with error messages
$results = array_filter($results);
if (count($results) === 0) {
$insertStatements = $sqlReader->getInsertStatementArray($sqlCode);
$results = $schemaMigrator->importStaticData($insertStatements);
}
foreach ($results as $statement => &$message) {
if ($message === '') {
unset($results[$statement]);
continue;
}
$message = new FlashMessage(
'Query:' . LF . ' ' . $statement . LF . 'Error:' . LF . ' ' . $message,
'Database query failed!',
ContextualFeedbackSeverity::ERROR
);
}
return array_values($results);
}
public function checkRequiredDatabasePermissions(): array
{
try {
return $this->databasePermissionsCheck
->checkCreateAndDrop()
->checkAlter()
->checkIndex()
->checkCreateTemporaryTable()
->checkInsert()
->checkSelect()
->checkUpdate()
->checkDelete()
->getMessages();
} catch (Exception $exception) {
return $this->databasePermissionsCheck->getMessages();
}
}
public function checkDatabaseRequirementsForDriver(string $databaseDriverName): FlashMessageQueue
{
$databaseCheck = GeneralUtility::makeInstance(DatabaseCheck::class);
try {
$databaseDriverClassName = DatabaseCheck::retrieveDatabaseDriverClassByDriverName($databaseDriverName);
$databaseCheck->checkDatabasePlatformRequirements($databaseDriverClassName);
$databaseCheck->checkDatabaseDriverRequirements($databaseDriverClassName);
return $databaseCheck->getMessageQueue();
} catch (\TYPO3\CMS\Install\Exception $exception) {
$flashMessageQueue = new FlashMessageQueue('database-check-requirements');
$flashMessageQueue->enqueue(
new FlashMessage(
'',
$exception->getMessage(),
ContextualFeedbackSeverity::INFO
)
);
return $flashMessageQueue;
}
}
public function getDriverOptions(): array
{
$hasAtLeastOneOption = false;
$activeAvailableOption = '';
$driverOptions = [];
if (DatabaseCheck::isMysqli()) {
$hasAtLeastOneOption = true;
$driverOptions['hasMysqliManualConfiguration'] = true;
$mysqliManualConfigurationOptions = [
'driver' => 'mysqli',
'username' => $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['user'] ?? '',
'password' => $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['password'] ?? '',
'port' => (int)($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['port'] ?? 3306),
'database' => $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['dbname'] ?? '',
];
$host = $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['host'] ?? '127.0.0.1';
if ($host === 'localhost') {
$host = '127.0.0.1';
}
$mysqliManualConfigurationOptions['host'] = $host;
$driverOptions['mysqliManualConfigurationOptions'] = $mysqliManualConfigurationOptions;
$activeAvailableOption = 'mysqliManualConfiguration';
$driverOptions['hasMysqliSocketManualConfiguration'] = true;
$driverOptions['mysqliSocketManualConfigurationOptions'] = [
'driver' => 'mysqli',
'username' => $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['user'] ?? '',
'password' => $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['password'] ?? '',
'socket' => $this->getDatabaseConfiguredMysqliSocket(),
'database' => $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['dbname'] ?? '',
];
if (($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['driver'] ?? '') === 'mysqli'
&& ($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['host'] ?? '') === 'localhost') {
$activeAvailableOption = 'mysqliSocketManualConfiguration';
}
}
if (DatabaseCheck::isPdoMysql()) {
$hasAtLeastOneOption = true;
$driverOptions['hasPdoMysqlManualConfiguration'] = true;
$pdoMysqlManualConfigurationOptions = [
'driver' => 'pdo_mysql',
'username' => $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['user'] ?? '',
'password' => $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['password'] ?? '',
'port' => (int)($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['port'] ?? 3306),
'database' => $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['dbname'] ?? '',
];
$host = $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['host'] ?? '127.0.0.1';
if ($host === 'localhost') {
$host = '127.0.0.1';
}
$pdoMysqlManualConfigurationOptions['host'] = $host;
$driverOptions['pdoMysqlManualConfigurationOptions'] = $pdoMysqlManualConfigurationOptions;
// preselect PDO MySQL only if mysqli is not present
if (!DatabaseCheck::isMysqli()) {
$activeAvailableOption = 'pdoMysqlManualConfiguration';
}
$driverOptions['hasPdoMysqlSocketManualConfiguration'] = true;
$driverOptions['pdoMysqlSocketManualConfigurationOptions'] = [
'driver' => 'pdo_mysql',
'username' => $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['user'] ?? '',
'password' => $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['password'] ?? '',
'socket' => $this->getDatabaseConfiguredPdoMysqlSocket(),
'database' => $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['dbname'] ?? '',
];
if (($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['driver'] ?? '') === 'pdo_mysql'
&& $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['host'] === 'localhost') {
$activeAvailableOption = 'pdoMysqlSocketManualConfiguration';
}
}
if (DatabaseCheck::isPdoPgsql()) {
$hasAtLeastOneOption = true;
$driverOptions['hasPostgresManualConfiguration'] = true;
$driverOptions['postgresManualConfigurationOptions'] = [
'driver' => 'pdo_pgsql',
'username' => $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['user'] ?? '',
'password' => $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['password'] ?? '',
'host' => $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['host'] ?? '127.0.0.1',
'port' => (int)($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['port'] ?? 5432),
'database' => $GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['dbname'] ?? '',
];
if (($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['driver'] ?? '') === 'pdo_pgsql') {
$activeAvailableOption = 'postgresManualConfiguration';
}
}
if (DatabaseCheck::isPdoSqlite()) {
$hasAtLeastOneOption = true;
$driverOptions['hasSqliteManualConfiguration'] = true;
$driverOptions['sqliteManualConfigurationOptions'] = [
'driver' => 'pdo_sqlite',
];
if (($GLOBALS['TYPO3_CONF_VARS']['DB']['Connections'][ConnectionPool::DEFAULT_CONNECTION_NAME]['driver'] ?? '') === 'pdo_sqlite') {
$activeAvailableOption = 'sqliteManualConfiguration';
}
}
if (!empty($this->getDatabaseConfigurationFromEnvironment())) {
$hasAtLeastOneOption = true;
$activeAvailableOption = 'configurationFromEnvironment';
$driverOptions['hasConfigurationFromEnvironment'] = true;
}
return array_merge($driverOptions, [
'hasAtLeastOneOption' => $hasAtLeastOneOption,
'activeAvailableOption' => $activeAvailableOption,
]);
}
public function markWizardsDone(ContainerInterface $container): void
{
foreach ($container->get(UpgradeWizardsService::class)->getNonRepeatableUpgradeWizards() as $className) {
$this->registry->set('installUpdate', $className, 1);
}
$this->registry->set('installUpdateRows', 'rowUpdatersDone', GeneralUtility::makeInstance(DatabaseRowsUpdateWizard::class)->getAvailableRowUpdater());
}
/**
* Set default connection charset, and in case of MySQL/MariaDB
* connections also defaultTableOptions charset and collation.
*
* Note that no check for pre-configured values are done. Thus,
* default values are set to enforce default values.
*/
private function setDefaultConnectionCharsetAndCollation(array $params): array
{
$params['charset'] = 'utf8';
if (isset($params['driver']) && in_array($params['driver'], ['mysqli', 'pdo_mysql'], true)) {
$params['charset'] = 'utf8mb4';
$params['defaultTableOptions'] = [
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
];
}
return $params;
}
}
+580
View File
@@ -0,0 +1,580 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service;
use Psr\Container\ContainerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\ConfigurationManager;
use TYPO3\CMS\Core\Configuration\Exception\SiteConfigurationWriteException;
use TYPO3\CMS\Core\Configuration\Loader\YamlFileLoader;
use TYPO3\CMS\Core\Configuration\SiteWriter;
use TYPO3\CMS\Core\Core\ClassLoadingInformation;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2idPasswordHash;
use TYPO3\CMS\Core\Crypto\PasswordHashing\Argon2iPasswordHash;
use TYPO3\CMS\Core\Crypto\PasswordHashing\BcryptPasswordHash;
use TYPO3\CMS\Core\Crypto\PasswordHashing\InvalidPasswordHashException;
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashInterface;
use TYPO3\CMS\Core\Crypto\Random;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Package\FailsafePackageManager;
use TYPO3\CMS\Core\Package\PackageInterface;
use TYPO3\CMS\Core\Package\PackageManager;
use TYPO3\CMS\Core\Package\PackageSetup;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Install\Command\BackendUserGroupType;
use TYPO3\CMS\Install\Configuration\FeatureManager;
use TYPO3\CMS\Install\FolderStructure\DefaultFactory;
use TYPO3\CMS\Install\Service\Exception\ConfigurationDirectoryDoesNotExistException;
use TYPO3\CMS\Install\Service\Exception\ConfigurationFileAlreadyExistsException;
use TYPO3\CMS\Install\WebserverType;
/**
* Service class helping to manage parts of the setup process (set configuration,
* create backend user, create a basic site, create default backend groups, etc.)
*
* @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
*
* @phpstan-type Distribution array{
* packageKey: string,
* title: string,
* description: string,
* isFramework: bool
* }
* @phpstan-type SplitDistributions array{
* inactive: array<string, Distribution>,
* active: array<string, Distribution>
* }
*/
#[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' => '<p>This website is made with <a href="https://typo3.org" target="_blank">TYPO3</a>.</p>',
]
);
$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 = <div style="max-width: 800px; margin: 2em auto;">|</div>
page.10.10 = TEXT
page.10.10.value (
<div style="width: 300px;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 42"><path d="M60.2 14.4v27h-3.8v-27h-6.7v-3.3h17.1v3.3h-6.6zm20.2 12.9v14h-3.9v-14l-7.7-16.2h4.1l5.7 12.2 5.7-12.2h3.9l-7.8 16.2zm19.5 2.6h-3.6v11.4h-3.8V11.1s3.7-.3 7.3-.3c6.6 0 8.5 4.1 8.5 9.4 0 6.5-2.3 9.7-8.4 9.7m.4-16c-2.4 0-4.1.3-4.1.3v12.6h4.1c2.4 0 4.1-1.6 4.1-6.3 0-4.4-1-6.6-4.1-6.6m21.5 27.7c-7.1 0-9-5.2-9-15.8 0-10.2 1.9-15.1 9-15.1s9 4.9 9 15.1c.1 10.6-1.8 15.8-9 15.8m0-27.7c-3.9 0-5.2 2.6-5.2 12.1 0 9.3 1.3 12.4 5.2 12.4 3.9 0 5.2-3.1 5.2-12.4 0-9.4-1.3-12.1-5.2-12.1m19.9 27.7c-2.1 0-5.3-.6-5.7-.7v-3.1c1 .2 3.7.7 5.6.7 2.2 0 3.6-1.9 3.6-5.2 0-3.9-.6-6-3.7-6H138V24h3.1c3.5 0 3.7-3.6 3.7-5.3 0-3.4-1.1-4.8-3.2-4.8-1.9 0-4.1.5-5.3.7v-3.2c.5-.1 3-.7 5.2-.7 4.4 0 7 1.9 7 8.3 0 2.9-1 5.5-3.3 6.3 2.6.2 3.8 3.1 3.8 7.3 0 6.6-2.5 9-7.3 9"/><path fill="#FF8700" d="M31.7 28.8c-.6.2-1.1.2-1.7.2-5.2 0-12.9-18.2-12.9-24.3 0-2.2.5-3 1.3-3.6C12 1.9 4.3 4.2 1.9 7.2 1.3 8 1 9.1 1 10.6c0 9.5 10.1 31 17.3 31 3.3 0 8.8-5.4 13.4-12.8M28.4.5c6.6 0 13.2 1.1 13.2 4.8 0 7.6-4.8 16.7-7.2 16.7-4.4 0-9.9-12.1-9.9-18.2C24.5 1 25.6.5 28.4.5"/></svg>
</div>
)
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);
}
}
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service;
use TYPO3\CMS\Install\Service\Exception\TemplateFileChangedException;
/**
* Execute "silent" upgrades for folder structure template files, if needed.
*
* Since the content of the template files may changed over time this class
* performs the necessary content changes in those files already present in
* the installation. It is called by the layout controller at an early point.
*
* Every change is encapsulated in one method and must throw a
* TemplateFileChangedException if its content was updated.
*
* @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
*/
readonly class SilentTemplateFileUpgradeService
{
public function __construct(protected WebServerConfigurationFileService $webServerConfigurationFileService) {}
/**
* Executed content changes. Single upgrade methods must throw a
* TemplateFileChangedException if content of the file was updated.
*
* @throws TemplateFileChangedException
*/
public function execute(): void
{
$this->addBackendRoutingRewriteRules();
}
/**
* @throws TemplateFileChangedException
*/
protected function addBackendRoutingRewriteRules(): void
{
$changed = $this->webServerConfigurationFileService->addWebServerSpecificBackendRoutingRewriteRules();
if ($changed) {
$this->throwTemplateFileChangedException();
}
}
/**
* Throw exception after template file content change to trigger a redirect.
*
* @throws TemplateFileChangedException
*/
protected function throwTemplateFileChangedException(): void
{
throw new TemplateFileChangedException(
'Template file updated, reload needed',
1608286894
);
}
}
+193
View File
@@ -0,0 +1,193 @@
<?php
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Resource\ProcessedFileRepository;
use TYPO3\CMS\Core\Resource\ResourceStorage;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Service class to manage typo3temp/assets and FAL storage
* processed file statistics / cleanup.
* @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
*/
readonly class Typo3tempFileService
{
public function __construct(
private ProcessedFileRepository $processedFileRepository,
private StorageRepository $storageRepository
) {}
/**
* Returns a list of directory names in typo3temp/assets and their number of files
*/
public function getDirectoryStatistics(): array
{
return array_merge(
$this->statsFromTypo3temp(),
$this->statsFromStorages()
);
}
public function getStatsFromStorageByUid(int $storageUid): array
{
if ($storageUid === 0) {
return $this->statsFromTypo3tempProcessed();
}
$storage = $this->storageRepository->findByUid($storageUid);
return $this->getStatsFromStorage($storage);
}
/**
* Directory statistics for typo3temp/assets folders with some
* special handling for legacy processed file storage _processed_
*/
protected function statsFromTypo3temp(): array
{
$stats = [];
$typo3TempAssetsPath = '/typo3temp/assets/';
$basePath = Environment::getPublicPath() . $typo3TempAssetsPath;
if (is_dir($basePath)) {
$dirFinder = new Finder();
$dirsInAssets = $dirFinder->directories()->ignoreUnreadableDirs()->in($basePath)->depth(0)->sortByName();
foreach ($dirsInAssets as $dirInAssets) {
/** @var SplFileInfo $dirInAssets */
$fileFinder = new Finder();
$fileCount = $fileFinder->files()->ignoreUnreadableDirs()->in($dirInAssets->getPathname())->count();
$folderName = $dirInAssets->getFilename();
$stat = [
'directory' => $typo3TempAssetsPath . $folderName,
'numberOfFiles' => $fileCount,
];
if ($folderName === '_processed_') {
// The processed file storage for legacy files (eg. TCA type=group internal_type=file)
// gets the storageUid set, so this one can be removed via FAL functionality
$stat['storageUid'] = 0;
}
$stats[] = $stat;
}
}
return $stats;
}
/**
* Directory statistics for typo3temp/assets/_processed_ folder
*/
protected function statsFromTypo3tempProcessed(): array
{
$typo3TempProcessedAssetsPath = '/typo3temp/assets/_processed_/';
$stats = [
'storageUid' => 0,
'directory' => $typo3TempProcessedAssetsPath,
];
$basePath = Environment::getPublicPath() . $typo3TempProcessedAssetsPath;
if (is_dir($basePath)) {
$fileFinder = new Finder();
$stats['numberOfFiles'] = $fileFinder->files()->in($basePath)->count();
} else {
$stats['numberOfFiles'] = 0;
}
return $stats;
}
/**
* Directory statistics for configured FAL storages.
*/
protected function statsFromStorages(): array
{
$stats = [];
$storages = $this->storageRepository->findAll();
foreach ($storages as $storage) {
if ($storage->isOnline()) {
$stats[] = $this->getStatsFromStorage($storage);
}
}
return $stats;
}
protected function getStatsFromStorage(ResourceStorage $storage): array
{
$storageConfiguration = $storage->getConfiguration();
$storageBasePath = rtrim($storageConfiguration['basePath'] ?? '', '/');
$processedPath = '/' . $storageBasePath . $storage->getProcessingFolder()->getIdentifier();
$numberOfFiles = $this->processedFileRepository->countByStorage($storage);
return [
'directory' => $processedPath,
'numberOfFiles' => $numberOfFiles,
'storageUid' => $storage->getUid(),
];
}
/**
* Clear processed files. The sys_file_processedfile table is cleared for
* given storage uid and the physical files of local processed storages are deleted.
*
* @return int 0 if all went well, if >0 this number of files that could not be deleted
*/
public function clearProcessedFiles(int $storageUid): int
{
return $this->processedFileRepository->removeAll($storageUid);
}
/**
* Clears files and folders in a typo3temp/assets/ folder (not _processed_!)
*
* @return bool TRUE if all went well
* @throws \RuntimeException If folder path is not valid
*/
public function clearAssetsFolder(string $folderName)
{
$basePath = Environment::getPublicPath() . $folderName;
if (empty($folderName)
|| !GeneralUtility::isAllowedAbsPath($basePath)
|| !str_starts_with($folderName, '/typo3temp/assets/')
) {
throw new \RuntimeException(
'Path to folder ' . $folderName . ' not allowed.',
1501781453
);
}
if (!is_dir($basePath)) {
throw new \RuntimeException(
'Folder path ' . $basePath . ' does not exist or is no directory.',
1501781454
);
}
// first remove directories
foreach ((new Finder())->directories()->ignoreUnreadableDirs()->in($basePath)->depth(0) as $directory) {
/** @var SplFileInfo $directory */
GeneralUtility::rmdir($directory->getPathname(), true);
}
// then remove files directly in the main dir
foreach ((new Finder())->files()->in($basePath)->depth(0) as $file) {
/** @var SplFileInfo $file */
$path = $file->getPathname();
@unlink($path);
}
return true;
}
}
@@ -0,0 +1,173 @@
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Install\Service;
use TYPO3\CMS\Core\Core\Environment;
/**
* Handles webserver specific configuration files
*
* @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
*/
class WebServerConfigurationFileService
{
protected string $webServer;
protected string $publicPath;
public function __construct()
{
$this->webServer = $this->getWebServer();
$this->publicPath = Environment::getPublicPath();
}
public function addWebServerSpecificBackendRoutingRewriteRules(): bool
{
$changed = false;
if ($this->isApache()) {
$changed = $this->addApacheBackendRoutingRewriteRules();
} elseif ($this->isMicrosoftIis()) {
$changed = $this->addMicrosoftIisBackendRoutingRewriteRules();
}
return $changed;
}
protected function addApacheBackendRoutingRewriteRules(): bool
{
$configurationFilename = $this->publicPath . '/.htaccess';
$configurationFileContent = $this->getConfigurationFileContent($configurationFilename);
if ($configurationFileContent === '' || !$this->updateNecessary($configurationFileContent)) {
return false;
}
$count = 0;
$configurationFileContent = preg_replace(
pattern: sprintf('/%s/', implode('\s*', array_map(
static fn($s) => preg_quote($s, '/'),
[
'RewriteCond %{REQUEST_FILENAME} !-d',
'RewriteCond %{REQUEST_FILENAME} !-l',
'RewriteRule ^typo3/(.*)$ %{ENV:CWD}typo3/index.php [QSA,L]',
]
))),
replacement: 'RewriteRule ^typo3/(.*)$ %{ENV:CWD}index.php [QSA,L]',
subject: $configurationFileContent,
count: $count
);
$configurationFileContent = str_replace(
[
'# Stop rewrite processing, if we are in any other known directory',
'# Stop rewrite processing, if we are in the typo3/ directory or any other known directory', // v10 style comment
'# If the file does not exist but is below /typo3/, redirect to the TYPO3 Backend entry point.',
],
[
'# Stop rewrite processing, if we are in any known directory',
'# Stop rewrite processing, if we are in any known directory',
'# If the file does not exist but is below /typo3/, rewrite to the main TYPO3 entry point.',
],
$configurationFileContent,
$count
);
// Return FALSE in case no replacement has been done. This might be the
// case if already modified versions of the configuration are in place.
return $count > 0 && file_put_contents($configurationFilename, $configurationFileContent);
}
protected function addMicrosoftIisBackendRoutingRewriteRules(): bool
{
$configurationFilename = $this->publicPath . '/web.config';
$configurationFileContent = $this->getConfigurationFileContent($configurationFilename);
if ($configurationFileContent === '' || !$this->updateNecessary($configurationFileContent)) {
return false;
}
$count = 0;
$configurationFileContent = str_replace(
[
'<rule name="TYPO3 - If the file/directory does not exist but is below /typo3/, redirect to the TYPO3 Backend entry point." stopProcessing="true">',
'<action type="Rewrite" url="typo3/index.php" appendQueryString="true" />',
],
[
'<rule name="TYPO3 - If the file/directory does not exist but is below /typo3/, redirect to the main TYPO3 entry point." stopProcessing="true">',
'<action type="Rewrite" url="index.php" appendQueryString="true" />',
],
$configurationFileContent,
$count
);
// Return FALSE in case no replacement has been done. This might be the
// case if already modified versions of the configuration are in place.
return $count > 0 && file_put_contents($configurationFilename, $configurationFileContent);
}
/**
* Returns the webserver configuration if it exists, is readable and is writeable
*
* @param string $filename The webserver configuration file name
* @return string The webserver configuration or an empty string
*/
protected function getConfigurationFileContent(string $filename): string
{
if (!file_exists($filename) || !is_readable($filename) || !is_writable($filename)) {
return '';
}
return file_get_contents($filename) ?: '';
}
/**
* Checks if the webserver configuration needs to be updated.
*
* This currently checks if the "known directory" rule still
* contains the `typo3` directory and the frontend rewrite rule
* exists. Later is needed since the backend rewrite rule must
* be placed before.
*
* @param string $configurationFileContent
*/
protected function updateNecessary(string $configurationFileContent): bool
{
if ($this->isApache()) {
return str_contains($configurationFileContent, 'RewriteRule ^typo3/(.*)$ %{ENV:CWD}typo3/index.php [QSA,L]');
}
if ($this->isMicrosoftIis()) {
return str_contains($configurationFileContent, '<action type="Rewrite" url="typo3/index.php" appendQueryString="true" />');
}
return false;
}
protected function isApache(): bool
{
return str_starts_with($this->webServer, 'Apache');
}
protected function isMicrosoftIis(): bool
{
return str_starts_with($this->webServer, 'Microsoft-IIS');
}
protected function getWebServer(): string
{
return $_SERVER['SERVER_SOFTWARE'] ?? '';
}
}