TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user