TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:09 +02:00
commit af8cc155b5
6818 changed files with 642608 additions and 0 deletions
@@ -0,0 +1,99 @@
<?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\Core\Upgrades;
use TYPO3\CMS\Core\Attribute\UpgradeWizard;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
/**
* Migrates backend user language from "default" to "en".
*
* @since 14.2
* @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
*/
#[UpgradeWizard('backendUserLanguageMigration')]
final readonly class BackendUserLanguageMigration implements UpgradeWizardInterface
{
private const string TABLE_NAME = 'be_users';
public function __construct(
private ConnectionPool $connectionPool
) {}
public function getTitle(): string
{
return 'Migrate backend user language from "default" to "en"';
}
public function getDescription(): string
{
$count = $this->getRecordsToUpdateCount();
return sprintf(
'The language key "default" for backend users has been replaced with "en". '
. 'This wizard migrates %d backend user record(s) to use "en" instead of "default".',
$count
);
}
public function updateNecessary(): bool
{
return $this->getRecordsToUpdateCount() > 0;
}
public function getPrerequisites(): array
{
return [
DatabaseUpdatedPrerequisite::class,
];
}
public function executeUpdate(): bool
{
$connection = $this->connectionPool->getConnectionForTable(self::TABLE_NAME);
$connection->update(
self::TABLE_NAME,
['lang' => 'en'],
['lang' => 'default']
);
return true;
}
private function getRecordsToUpdateCount(): int
{
$queryBuilder = $this->getPreparedQueryBuilder();
return (int)$queryBuilder
->count('uid')
->from(self::TABLE_NAME)
->where(
$queryBuilder->expr()->eq(
'lang',
$queryBuilder->createNamedParameter('default')
)
)
->executeQuery()
->fetchOne();
}
private function getPreparedQueryBuilder(): QueryBuilder
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->getRestrictions()->removeAll();
return $queryBuilder;
}
}
+31
View File
@@ -0,0 +1,31 @@
<?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\Core\Upgrades;
use Symfony\Component\Console\Output\OutputInterface;
/**
* Is this upgradeWizard chatty aka does it need to output things?
*/
interface ChattyInterface
{
/**
* Setter injection for output into upgrade wizards
*/
public function setOutput(OutputInterface $output): void;
}
+29
View File
@@ -0,0 +1,29 @@
<?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\Core\Upgrades;
/**
* Use if upgrade wizard needs confirmation
*/
interface ConfirmableInterface
{
/**
* Return a confirmation message instance
*/
public function getConfirmation(): Confirmation;
}
+60
View File
@@ -0,0 +1,60 @@
<?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\Core\Upgrades;
readonly class Confirmation
{
public function __construct(
protected string $title,
protected string $message,
protected bool $defaultValue = false,
protected string $confirm = 'Yes, execute',
protected string $deny = 'No, do not execute',
protected bool $required = false
) {}
public function getConfirm(): string
{
return $this->confirm;
}
public function getDeny(): string
{
return $this->deny;
}
public function isRequired(): bool
{
return $this->required;
}
public function getDefaultValue(): bool
{
return $this->defaultValue;
}
public function getTitle(): string
{
return $this->title;
}
public function getMessage(): string
{
return $this->message;
}
}
@@ -0,0 +1,336 @@
<?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\Core\Upgrades;
use TYPO3\CMS\Core\Attribute\UpgradeWizard;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Registry;
use TYPO3\CMS\Core\Upgrades\RowUpdater\RowUpdaterInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This is a generic updater to migrate content of TCA rows.
*
* Multiple classes implementing interface "RowUpdaterInterface" can be
* registered here, each for a specific update purpose.
*
* The updater fetches each row of all TCA registered tables and
* visits the client classes who may modify the row content.
*
* The updater remembers for each class if it run through, so the updater
* will be shown again if a new updater class is registered that has not
* been run yet.
*
* A start position pointer is stored in the registry that is updated during
* the run process, so if for instance the PHP process runs into a timeout,
* the job can restart at the position it stopped.
*
* @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
*/
#[UpgradeWizard('databaseRowsUpdateWizard')]
class DatabaseRowsUpdateWizard implements UpgradeWizardInterface, RepeatableInterface
{
/**
* @var array Single classes that may update rows
* @todo No RowUpdater remaining allowing us to move the registration to attribute/interface based locator.
*/
protected $rowUpdater = [];
/**
* @internal
* @return string[]
*/
public function getAvailableRowUpdater(): array
{
return $this->rowUpdater;
}
/**
* @return string Title of this updater
*/
public function getTitle(): string
{
return 'Execute database migrations on single rows';
}
/**
* @return string Longer description of this updater
* @throws \RuntimeException
*/
public function getDescription(): string
{
$rowUpdaterNotExecuted = $this->getRowUpdatersToExecute();
$description = 'Row updaters that have not been executed:';
foreach ($rowUpdaterNotExecuted as $rowUpdateClassName) {
$rowUpdater = GeneralUtility::makeInstance($rowUpdateClassName);
if (!$rowUpdater instanceof RowUpdaterInterface) {
throw new \RuntimeException(
'Row updater must implement RowUpdaterInterface',
1484066647
);
}
$description .= LF . $rowUpdater->getTitle();
}
return $description;
}
/**
* @return bool True if at least one row updater is not marked done
*/
public function updateNecessary(): bool
{
return !empty($this->getRowUpdatersToExecute());
}
/**
* @return string[] All new fields and tables must exist
*/
public function getPrerequisites(): array
{
return [
DatabaseUpdatedPrerequisite::class,
];
}
/**
* Performs the configuration update.
*
* @throws \Doctrine\DBAL\ConnectionException
* @throws \Exception
*/
public function executeUpdate(): bool
{
$registry = GeneralUtility::makeInstance(Registry::class);
// If rows from the target table that is updated and the sys_registry table are on the
// same connection, the row update statement and sys_registry position update will be
// handled in a transaction to have an atomic operation in case of errors during execution.
$connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
$connectionForSysRegistry = $connectionPool->getConnectionForTable('sys_registry');
/** @var RowUpdaterInterface[] $rowUpdaterInstances */
$rowUpdaterInstances = [];
// Single row updater instances are created only once for this method giving
// them a chance to set up local properties during hasPotentialUpdateForTable()
// and using that in updateTableRow()
foreach ($this->getRowUpdatersToExecute() as $rowUpdater) {
$rowUpdaterInstance = GeneralUtility::makeInstance($rowUpdater);
if (!$rowUpdaterInstance instanceof RowUpdaterInterface) {
throw new \RuntimeException(
'Row updater must implement RowUpdaterInterface',
1484071612
);
}
$rowUpdaterInstances[] = $rowUpdaterInstance;
}
// Scope of the row updater is to update all rows that have TCA,
// our list of tables is just the list of loaded TCA tables.
/** @var string[] $listOfAllTables */
$listOfAllTables = array_keys($GLOBALS['TCA']);
// In case the PHP ended for whatever reason, fetch the last position from registry
// and throw away all tables before that start point.
sort($listOfAllTables);
reset($listOfAllTables);
$firstTable = current($listOfAllTables) ?: '';
$startPosition = $this->getStartPosition($firstTable);
foreach ($listOfAllTables as $key => $table) {
if ($table === $startPosition['table']) {
break;
}
unset($listOfAllTables[$key]);
}
// Ask each row updater if it potentially has field updates for rows of a table
$tableToUpdaterList = [];
foreach ($listOfAllTables as $table) {
foreach ($rowUpdaterInstances as $updater) {
if ($updater->hasPotentialUpdateForTable($table)) {
$tableToUpdaterList[$table] ??= [];
$tableToUpdaterList[$table][] = $updater;
}
}
}
// Iterate through all rows of all tables that have potential row updaters attached,
// feed each single row to each updater and finally update each row in database if
// a row updater changed a fields
foreach ($tableToUpdaterList as $table => $updaters) {
/** @var RowUpdaterInterface[] $updaters */
$connectionForTable = $connectionPool->getConnectionForTable($table);
$queryBuilder = $connectionPool->getQueryBuilderForTable($table);
$queryBuilder->getRestrictions()->removeAll();
$queryBuilder->select('*')
->from($table)
->orderBy('uid');
if ($table === $startPosition['table']) {
$queryBuilder->where(
$queryBuilder->expr()->gt('uid', $queryBuilder->createNamedParameter($startPosition['uid']))
);
}
$statement = $queryBuilder->executeQuery();
$rowCountWithoutUpdate = 0;
while ($row = $statement->fetchAssociative()) {
$rowBefore = $row;
foreach ($updaters as $updater) {
$row = $updater->updateTableRow($table, $row);
}
$updatedFields = array_diff_assoc($row, $rowBefore);
if (empty($updatedFields)) {
// Updaters changed no field of that row
$rowCountWithoutUpdate++;
if ($rowCountWithoutUpdate >= 200) {
// Update startPosition if there were many rows without data change
$startPosition = [
'table' => $table,
'uid' => $row['uid'],
];
$registry->set('installUpdateRows', 'rowUpdatePosition', $startPosition);
$rowCountWithoutUpdate = 0;
}
} else {
$rowCountWithoutUpdate = 0;
$startPosition = [
'table' => $table,
'uid' => $rowBefore['uid'],
];
if ($connectionForSysRegistry === $connectionForTable) {
// Target table and sys_registry table are on the same connection, use a transaction
$connectionForTable->beginTransaction();
try {
$this->updateOrDeleteRow(
$connectionForTable,
$connectionForTable,
$table,
(int)$rowBefore['uid'],
$updatedFields,
$startPosition
);
$connectionForTable->commit();
} catch (\Exception $up) {
$connectionForTable->rollBack();
throw $up;
}
} else {
// Different connections for table and sys_registry.
// So, execute two distinct queries and hope for the best.
$this->updateOrDeleteRow(
$connectionForTable,
$connectionForSysRegistry,
$table,
(int)$rowBefore['uid'],
$updatedFields,
$startPosition
);
}
}
}
}
// Ready with updates, remove position information from sys_registry
$registry->remove('installUpdateRows', 'rowUpdatePosition');
// Mark row updaters that were executed as done
foreach ($rowUpdaterInstances as $updater) {
$this->setRowUpdaterExecuted($updater);
}
return true;
}
/**
* Return an array of class names that are not yet marked as done.
*
* @return array Class names
*/
protected function getRowUpdatersToExecute(): array
{
$doneRowUpdater = GeneralUtility::makeInstance(Registry::class)->get('installUpdateRows', 'rowUpdatersDone', []);
return array_diff($this->rowUpdater, $doneRowUpdater);
}
/**
* Mark a single updater as done
*/
protected function setRowUpdaterExecuted(RowUpdaterInterface $updater)
{
$registry = GeneralUtility::makeInstance(Registry::class);
$doneRowUpdater = $registry->get('installUpdateRows', 'rowUpdatersDone', []);
$doneRowUpdater[] = get_class($updater);
$registry->set('installUpdateRows', 'rowUpdatersDone', $doneRowUpdater);
}
/**
* Return an array with table / uid combination that specifies the start position the
* update row process should start with.
*
* @param string $firstTable Table name of the first TCA in case the start position needs to be initialized
* @return array New start position
*/
protected function getStartPosition(string $firstTable): array
{
$registry = GeneralUtility::makeInstance(Registry::class);
$startPosition = $registry->get('installUpdateRows', 'rowUpdatePosition', []);
if (empty($startPosition)) {
$startPosition = [
'table' => $firstTable,
'uid' => 0,
];
$registry->set('installUpdateRows', 'rowUpdatePosition', $startPosition);
}
return $startPosition;
}
protected function updateOrDeleteRow(Connection $connectionForTable, Connection $connectionForSysRegistry, string $table, int $uid, array $updatedFields, array $startPosition): void
{
$deleteField = $GLOBALS['TCA'][$table]['ctrl']['delete'] ?? null;
if ($deleteField === null && isset($updatedFields['deleted']) && $updatedFields['deleted'] === 1) {
$connectionForTable->delete(
$table,
[
'uid' => $uid,
]
);
} else {
$connectionForTable->update(
$table,
$updatedFields,
[
'uid' => $uid,
]
);
}
$connectionForSysRegistry->update(
'sys_registry',
[
'entry_value' => serialize($startPosition),
],
[
'entry_namespace' => 'installUpdateRows',
'entry_key' => 'rowUpdatePosition',
],
[
// Needs to be declared LOB, so MSSQL can handle the conversion from string (nvarchar) to blob (varbinary)
'entry_value' => Connection::PARAM_LOB,
'entry_namespace' => Connection::PARAM_STR,
'entry_key' => Connection::PARAM_STR,
]
);
}
}
@@ -0,0 +1,66 @@
<?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\Core\Upgrades;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Service\DatabaseUpgradeWizardsService;
/**
* Prerequisite for upgrade wizards to ensure the database is up-to-date
*/
#[Autoconfigure(public: true)]
final class DatabaseUpdatedPrerequisite implements PrerequisiteInterface, ChattyInterface
{
private OutputInterface $output;
public function __construct(
private readonly DatabaseUpgradeWizardsService $databaseUpgradeWizardsService,
) {}
public function getTitle(): string
{
return 'Database Up-to-Date';
}
public function ensure(): bool
{
$adds = $this->databaseUpgradeWizardsService->getBlockingDatabaseAdds();
// Nothing to add, early return
if ($adds === []) {
return true;
}
$this->output->writeln('Performing ' . count($adds) . ' database operations.');
// remove potentially empty error messages
$errorMessages = array_filter($this->databaseUpgradeWizardsService->addMissingTablesAndFields());
return $errorMessages === [];
}
public function isFulfilled(): bool
{
$adds = $this->databaseUpgradeWizardsService->getBlockingDatabaseAdds();
return count($adds) === 0;
}
public function setOutput(OutputInterface $output): void
{
$this->output = $output;
}
}
@@ -0,0 +1,168 @@
<?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\Core\Upgrades;
use TYPO3\CMS\Core\Attribute\UpgradeWizard;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Registry;
use TYPO3\CMS\Core\Upgrades\DatabaseUpdatedPrerequisite as CoreDatabaseUpdatedPrerequisite;
use TYPO3\CMS\Core\Upgrades\UpgradeWizardInterface as CoreUpgradeWizardInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Migrate extension data import registry keys from path-based to extension key-based format
*
* This wizard updates sys_registry entries that were stored with file paths to use
* extension keys as prefix instead, making them independent of file path changes.
*
* @since 14.0
* @internal This class is only meant to be used within `EXT:core` and is not part of the TYPO3 Core API.
*/
#[UpgradeWizard('migrateExtensionDataImportRegistryKeys')]
readonly class MigrateExtensionDataImportRegistryKeysUpdate implements CoreUpgradeWizardInterface
{
public function getTitle(): string
{
return 'Migrate extension data import registry keys';
}
public function getDescription(): string
{
return 'Updates sys_registry entries for extension data imports from path-based keys to extension key-based keys. '
. 'This makes the registry entries independent of file path changes and follows the new format introduced '
. 'in the extension data import system.';
}
public function executeUpdate(): bool
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('sys_registry');
// Get all extensionDataImport registry entries
$queryBuilder = $connection->createQueryBuilder();
$result = $queryBuilder
->select('entry_key', 'entry_value')
->from('sys_registry')
->where(
$queryBuilder->expr()->eq(
'entry_namespace',
$queryBuilder->createNamedParameter('extensionDataImport')
)
)
->orderBy('uid')
->executeQuery();
$registry = GeneralUtility::makeInstance(Registry::class);
while ($row = $result->fetchAssociative()) {
$oldKey = $row['entry_key'];
$value = $row['entry_value'];
if ($oldKey === '') {
continue;
}
// Skip entries that already use the new format (contain ":")
if (str_contains($oldKey, ':') && !str_starts_with($oldKey, 'EXT:')) {
continue;
}
$newKey = $this->convertPathToExtensionKey($oldKey);
if ($newKey !== null && $newKey !== $oldKey) {
// Set the new key with the same value
$registry->set('extensionDataImport', $newKey, unserialize($value, ['allowed_classes' => false]));
// Remove the old key
$registry->remove('extensionDataImport', $oldKey);
}
}
return true;
}
public function updateNecessary(): bool
{
$connection = GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionForTable('sys_registry');
$queryBuilder = $connection->createQueryBuilder();
$count = $queryBuilder
->count('*')
->from('sys_registry')
->where(
$queryBuilder->expr()->eq(
'entry_namespace',
$queryBuilder->createNamedParameter('extensionDataImport')
),
$queryBuilder->expr()->notLike(
'entry_key',
$queryBuilder->createNamedParameter('%:%')
)
)
->executeQuery()
->fetchOne();
return $count > 0;
}
public function getPrerequisites(): array
{
return [
CoreDatabaseUpdatedPrerequisite::class,
];
}
/**
* Convert a path-based registry key to an extension key-based format
*/
protected function convertPathToExtensionKey(string $pathKey): ?string
{
// Pattern 1: typo3conf/ext/... or typo3/sysext/...
if (preg_match('#^(?:typo3conf/ext|typo3/sysext)/([^/]+)/(.+)$#', $pathKey, $matches)) {
$extensionKey = $matches[1];
$filePart = $matches[2];
return $extensionKey . ':' . $filePart;
}
// Pattern 2: EXT:extension_name/...
if (preg_match('#^EXT:([^/]+)/(.+)$#', $pathKey, $matches)) {
$extensionKey = $matches[1];
$filePart = $matches[2];
return $extensionKey . ':' . $filePart;
}
// Pattern 3: composer-based mode (vendor/...), in this case we take the last path part before
// "Initialisation/Files" or "ext_tables_static+adt.sql"
$pathSegments = GeneralUtility::revExplode('/', $pathKey, 2);
if (count($pathSegments) !== 2) {
return null;
}
if ($pathSegments[1] === 'Files' || $pathSegments[1] === 'dataImported') {
$pathSegments[0] = GeneralUtility::revExplode('/', $pathSegments[0], 2)[0];
$pathSegments[1] = 'Initialisation/' . $pathSegments[1];
}
if (preg_match('#^([^/]+)/(.+)$#', $pathSegments[0], $matches) && in_array($pathSegments[1], ['Initialisation/Files', 'Initialisation/dataImported', 'ext_tables_static+adt.sql'])) {
$extensionKey = (GeneralUtility::revExplode('/', $matches[0], 2)[1] ?? '');
$extensionKey = str_replace('-', '_', $extensionKey); // Normalize dashes to underscores
if (str_starts_with($extensionKey, 'cms_')) {
$extensionKey = substr($extensionKey, strlen('cms_'));
}
$filePart = $pathSegments[1];
return $extensionKey . ':' . $filePart;
}
return null;
}
}
@@ -0,0 +1,200 @@
<?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\Core\Upgrades;
use Doctrine\DBAL\ParameterType;
use Doctrine\DBAL\Schema\Table;
use Symfony\Component\Console\Output\OutputInterface;
use TYPO3\CMS\Core\Attribute\UpgradeWizard;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Domain\Repository\PageRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Migrates `pages.url` field values for `pages.doktype = 3 (Link)` to
* TypoLink notation suitable for the `pages.link` field and displays
* failed pages uid.
*
* @since 14.0
* @internal This class is only meant to be used within EXT:core and is not part of the TYPO3 Core API.
* @todo Remove in 16.0 as breaking change.
*/
#[UpgradeWizard('pageDoktypeLinkMigration')]
class PageDoktypeLinkMigration implements UpgradeWizardInterface, ChattyInterface
{
protected ?OutputInterface $output = null;
public function __construct(
private readonly ConnectionPool $connectionPool,
) {}
public function setOutput(OutputInterface $output): void
{
$this->output = $output;
}
public function getTitle(): string
{
return 'Migrate field "pages.url" to "pages.link" for pages of type Link.';
}
public function getDescription(): string
{
return 'Migrates "pages.url" to "pages.link", preserving former behaviour of the page type "External Link".';
}
public function getPrerequisites(): array
{
return [
DatabaseUpdatedPrerequisite::class,
];
}
public function updateNecessary(): bool
{
$tableSchema = $this->getPagesTableSchema();
return $tableSchema !== null
&& $tableSchema->hasColumn('url')
&& $tableSchema->hasColumn('target')
&& $tableSchema->hasColumn('link')
&& $this->hasRecordsToUpdate();
}
public function executeUpdate(): bool
{
if (!$this->updateNecessary()) {
return true;
}
$connection = $this->connectionPool->getConnectionForTable('pages');
$migratableItemsQueryBuilder = $connection->createQueryBuilder();
$result = $migratableItemsQueryBuilder
->select('uid', 'url', 'target', 'deleted')
->from('pages')
->where(
$migratableItemsQueryBuilder->expr()->and(
$migratableItemsQueryBuilder->expr()->eq('link', $migratableItemsQueryBuilder->createNamedParameter('')),
$migratableItemsQueryBuilder->expr()->neq('url', $migratableItemsQueryBuilder->createNamedParameter('')),
$migratableItemsQueryBuilder->expr()->eq('doktype', $migratableItemsQueryBuilder->createNamedParameter(PageRepository::DOKTYPE_LINK, ParameterType::INTEGER)),
),
)
->executeQuery();
$scheme = $GLOBALS['TYPO3_CONF_VARS']['SYS']['defaultScheme'] ?? 'http';
$failedMigrations = [];
try {
while ($row = $result->fetchAssociative()) {
$url = $this->migrateExternalUrlToTypoLink($row['url'], $row['target'], $scheme);
if ($url === '') {
if ($row['deleted'] !== 1) {
$failedMigrations[] = $row['uid'];
}
continue;
}
$updateQueryBuilder = $connection->createQueryBuilder();
$updateQueryBuilder->getRestrictions()->removeAll();
$expression = $updateQueryBuilder->expr();
$updateQueryBuilder->update('pages')
->set('link', $url)
// Empty url field to flag already migrated record.
->set('url', '')
// Empty target field
->set('target', '')
->where($expression->eq('uid', ($row['uid'])))
->executeStatement();
}
} finally {
// Ensure to buffer is freed in case any exception occurred to avoid follow issues.
$result->free();
}
if ($failedMigrations !== []) {
$this->output?->writeln(sprintf(
'The following pages of type "Link" could not be migrated: %s',
implode(', ', $failedMigrations),
));
}
return true;
}
protected function hasRecordsToUpdate(): bool
{
$connection = $this->connectionPool->getConnectionForTable('pages');
$migratableItemsQueryBuilder = $connection->createQueryBuilder();
return (bool)$migratableItemsQueryBuilder
->count('*')
->from('pages')
->where(
$migratableItemsQueryBuilder->expr()->and(
$migratableItemsQueryBuilder->expr()->eq('link', $migratableItemsQueryBuilder->createNamedParameter('')),
$migratableItemsQueryBuilder->expr()->neq('url', $migratableItemsQueryBuilder->createNamedParameter('')),
$migratableItemsQueryBuilder->expr()->eq('doktype', $migratableItemsQueryBuilder->createNamedParameter(PageRepository::DOKTYPE_LINK, ParameterType::INTEGER)),
),
)->executeQuery()->fetchOne();
}
protected function migrateExternalUrlToTypoLink(string $urlString, string $target, string $sitePrefix): string
{
$urlTargetSuffix = $target !== '' ? ' ' . $target : '';
if ($urlString === '') {
return '';
}
// Old ExternalUrl field allowed to simply define query parameters appended to the current page,
// which TypoScript still supports. Simply keep/copy the option.
if (str_starts_with($urlString, '?')) {
return $urlString . $urlTargetSuffix;
}
$parsedUrl = parse_url($urlString);
if (str_starts_with($urlString, 'mailto:')) {
if (GeneralUtility::validEmail(substr($urlString, 7))) {
// valid mailto: URL
return $urlString . $urlTargetSuffix;
}
return '';
}
if (!($parsedUrl['scheme'] ?? false)) {
if (GeneralUtility::validEmail($urlString)) {
// Email Address without mailto prefix
return 'mailto:' . $urlString;
}
if (str_starts_with($urlString, '/')) {
// Relative Url on site base
return $urlString . $urlTargetSuffix;
}
// domain without https prefix
$urlString = $sitePrefix . '://' . $urlString;
}
if (!GeneralUtility::isValidUrl($urlString)) {
return '';
}
// Reject any non-http(s) schemes (mailto: already handled above)
// This rejects javascript: ftp: and other potentially harmful prefixes
$scheme = strtolower((string)(parse_url($urlString, PHP_URL_SCHEME) ?? ''));
if ($scheme !== '' && $scheme !== 'http' && $scheme !== 'https') {
return '';
}
return $urlString . $urlTargetSuffix;
}
protected function getPagesTableSchema(): ?Table
{
$schemaManager = $this->connectionPool->getConnectionForTable('pages')->createSchemaManager();
if (!$schemaManager->tablesExist(['pages'])) {
return null;
}
return $schemaManager->introspectTable('pages');
}
}
@@ -0,0 +1,52 @@
<?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\Core\Upgrades;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Collection of prerequisites used internally in upgrade wizard commands.
*
* @internal for use in upgrade wizard command only and not part of public API.
*/
final class PrerequisiteCollection implements \IteratorAggregate
{
private \ArrayObject $prerequisites;
public function __construct()
{
$this->prerequisites = new \ArrayObject();
}
public function add(string $prerequisiteClass): void
{
if (
!($this->prerequisites[$prerequisiteClass] ?? false)
&& is_a($prerequisiteClass, PrerequisiteInterface::class, true)
) {
$this->prerequisites[$prerequisiteClass] = GeneralUtility::makeInstance(
$prerequisiteClass
);
}
}
public function getIterator(): \Traversable
{
return $this->prerequisites;
}
}
@@ -0,0 +1,51 @@
<?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\Core\Upgrades;
/**
* UpgradeWizard Prerequisites
*/
interface PrerequisiteInterface
{
/**
* Get speaking name of this prerequisite
*/
public function getTitle(): string;
/**
* Ensure this prerequisite is fulfilled
*
* Gets called if "isFulfilled" returns false
* and should ensure the prerequisite
*
* Returns true on success, false on error
*
* @see isFulfilled
*/
public function ensure(): bool;
/**
* Is this prerequisite met?
*
* Checks whether this prerequisite is fulfilled. If it is not,
* ensure should be called to fulfill it.
*
* @see ensure
*/
public function isFulfilled(): bool;
}
@@ -0,0 +1,72 @@
<?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\Core\Upgrades;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Command\ProgressListener\ReferenceIndexProgressListener;
use TYPO3\CMS\Core\Database\ReferenceIndex;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* ReferenceIndex Prerequisite
*
* Defines that the reference index needs to be up-to-date before an upgrade wizard may be run
*/
#[Autoconfigure(public: true)]
final class ReferenceIndexUpdatedPrerequisite implements PrerequisiteInterface, ChattyInterface
{
private OutputInterface $output;
public function __construct(
private readonly ReferenceIndex $referenceIndex,
) {}
public function getTitle(): string
{
return 'Reference Index Up-to-Date';
}
/**
* Updates the reference index
*/
public function ensure(): bool
{
$this->output->writeln('Reference Index is being updated');
$progressListener = GeneralUtility::makeInstance(ReferenceIndexProgressListener::class);
$progressListener->initialize(new SymfonyStyle(new ArrayInput([]), $this->output));
$result = $this->referenceIndex->updateIndex(false, $progressListener);
return empty($result['errors']);
}
/**
* Checks whether there are reference index updates to be done
*/
public function isFulfilled(): bool
{
$result = $this->referenceIndex->updateIndex(true);
return empty($result['errors']);
}
public function setOutput(OutputInterface $output): void
{
$this->output = $output;
}
}
+25
View File
@@ -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\Core\Upgrades;
/**
* Use if wizard may be run multiple times (and should not be disabled after one run)
*
* Semantic/Marker interface only
*/
interface RepeatableInterface {}
@@ -0,0 +1,45 @@
<?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\Core\Upgrades\RowUpdater;
/**
* Interface each single row updater must implement.
*/
interface RowUpdaterInterface
{
/**
* Get a description of this single row updater
*/
public function getTitle(): string;
/**
* Return true if this row updater may have updates for given table rows.
*
* @param string $tableName Given table
*/
public function hasPotentialUpdateForTable(string $tableName): bool;
/**
* Update a single row from a table.
*
* @param string $tableName Given table
* @param array $row Given row
* @return array Potentially modified row
*/
public function updateTableRow(string $tableName, array $row): array;
}
@@ -0,0 +1,59 @@
<?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\Core\Upgrades;
/**
* Interface UpgradeWizardInterface
*/
interface UpgradeWizardInterface
{
/**
* Return the speaking name of this wizard
*/
public function getTitle(): string;
/**
* Return the description for this wizard
*/
public function getDescription(): string;
/**
* Execute the update
*
* Called when a wizard reports that an update is necessary
*/
public function executeUpdate(): bool;
/**
* Is an update necessary?
*
* Is used to determine whether a wizard needs to be run.
* Check if data for migration exists.
*/
public function updateNecessary(): bool;
/**
* Returns an array of class names of Prerequisite classes
*
* This way a wizard can define dependencies like "database up-to-date" or
* "reference index updated"
*
* @return string[]
*/
public function getPrerequisites(): array;
}
@@ -0,0 +1,65 @@
<?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\Core\Upgrades;
use Symfony\Component\DependencyInjection\Attribute\AutowireLocator;
use Symfony\Component\DependencyInjection\ServiceLocator;
/**
* Registry for upgrade wizards. The registry receives all services, tagged with "install.upgradewizard".
* The tagging of upgrade wizards is automatically done based on the PHP Attribute UpgradeWizard.
*
* @internal
*/
readonly class UpgradeWizardRegistry
{
public function __construct(
#[AutowireLocator(services: 'install.upgradewizard', indexAttribute: 'identifier')]
private ServiceLocator $upgradeWizards
) {}
/**
* Whether a registered upgrade wizard exists for the given identifier
*/
public function hasUpgradeWizard(string $identifier): bool
{
return $this->upgradeWizards->has($identifier);
}
/**
* Get registered upgrade wizard by identifier
*/
public function getUpgradeWizard(string $identifier): UpgradeWizardInterface
{
if (!$this->hasUpgradeWizard($identifier)) {
throw new \UnexpectedValueException('Upgrade wizard with identifier ' . $identifier . ' is not registered.', 1673964964);
}
return $this->upgradeWizards->get($identifier);
}
/**
* Get all registered upgrade wizards
*
* @return array
*/
public function getUpgradeWizards(): array
{
return $this->upgradeWizards->getProvidedServices();
}
}
@@ -0,0 +1,151 @@
<?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\Core\Upgrades;
use TYPO3\CMS\Core\Attribute\UpgradeWizard;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* @since 14.0
* @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
*/
#[UpgradeWizard('userPermissionsForRenamedModulesMigration')]
class UserPermissionsForRenamedModulesMigration implements UpgradeWizardInterface
{
protected array $tables = [
'be_groups' => 'groupMods',
'be_users' => 'userMods',
];
/**
* @var array <string, string> an array with the old module identifier as key and the new one as value
*/
protected array $moduleRenaming = [
'web_list' => 'records',
'web_info' => 'content_status',
'workspaces_admin' => 'workspaces_publish',
'site_redirects' => 'redirects',
'web_linkvalidator' => 'linkvalidator_checklinks',
];
/**
* Modules that require a parent module to be accessible.
*
* @var array<string, string> Key: the new module identifier, Value: required parent module
*/
protected array $requiredParentModules = [
'redirects' => 'link_management',
'linkvalidator_checklinks' => 'link_management',
];
public function getTitle(): string
{
return 'Migrate module permissions';
}
public function getDescription(): string
{
return 'Migrate permissions for renamed modules in user and group module permissions. '
. 'Also adds required parent modules when a module has been moved to a new location in the module hierarchy.';
}
public function getPrerequisites(): array
{
return [
DatabaseUpdatedPrerequisite::class,
];
}
public function updateNecessary(): bool
{
return $this->migrate(true);
}
public function executeUpdate(): bool
{
return $this->migrate(false);
}
private function migrate(bool $dryRun): bool
{
$migrated = false;
foreach ($this->tables as $table => $field) {
$queryBuilder = $this->getConnectionPool()->getQueryBuilderForTable($table);
$connection = $this->getConnectionPool()->getConnectionForTable($table);
$queryBuilder->select('uid', $field)
->from($table)
->executeQuery();
foreach ($queryBuilder->fetchAllAssociative() as $record) {
$originalModules = (string)($record[$field] ?? '');
$modules = explode(',', $originalModules);
$parentModulesToAdd = [];
$updatedModules = array_map(function ($module) use (&$parentModulesToAdd) {
$trimmedModule = trim($module);
if (isset($this->moduleRenaming[$trimmedModule])) {
$newModule = $this->moduleRenaming[$trimmedModule];
if (isset($this->requiredParentModules[$newModule])) {
$parentModulesToAdd[] = $this->requiredParentModules[$newModule];
}
return $newModule;
}
return $module;
}, $modules);
$trimmedModules = array_filter(array_map('trim', $updatedModules));
$deduplicatedModules = array_unique($trimmedModules);
// Check if existing modules (already renamed in a previous migration) need parent modules
// This handles the case where the wizard ran in v14.0 before $requiredParentModules existed
foreach ($deduplicatedModules as $module) {
if (isset($this->requiredParentModules[$module])) {
$parentModulesToAdd[] = $this->requiredParentModules[$module];
}
}
// Add parent modules if not already present
foreach (array_unique($parentModulesToAdd) as $parentModule) {
if (!in_array($parentModule, $deduplicatedModules, true)) {
$deduplicatedModules[] = $parentModule;
}
}
$newModules = implode(',', $deduplicatedModules);
if ($originalModules !== $newModules) {
if ($dryRun) {
return true;
}
$migrated = $connection->update(
$table,
[
$field => $newModules,
],
['uid' => (int)$record['uid']]
) > 0 || $migrated;
}
}
}
return $migrated;
}
protected function getConnectionPool(): ConnectionPool
{
return GeneralUtility::makeInstance(ConnectionPool::class);
}
}