TYPO3 v15 dev-main snapshot ()

This commit is contained in:
2026-08-10 22:31:00 +02:00
commit f9941541b7
1178 changed files with 135377 additions and 0 deletions
@@ -0,0 +1,417 @@
<?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\Backend\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ChoiceQuestion;
use Symfony\Component\Console\Question\ConfirmationQuestion;
use Symfony\Component\Console\Question\Question;
use TYPO3\CMS\Core\Attribute\AsNonSchedulableCommand;
use TYPO3\CMS\Core\Configuration\ConfigurationManager;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Localization\Locales;
use TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyAction;
use TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyValidator;
use TYPO3\CMS\Core\PasswordPolicy\Validator\Dto\ContextData;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
/**
* Create a new backend user
*/
#[AsCommand('backend:user:create', 'Create a backend user.')]
#[AsNonSchedulableCommand]
class CreateBackendUserCommand extends Command
{
public function __construct(
private readonly ConnectionPool $connectionPool,
private readonly ConfigurationManager $configurationManager,
private readonly LanguageServiceFactory $languageServiceFactory,
private readonly Locales $locales,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption(
'username',
'u',
InputOption::VALUE_REQUIRED,
'The username of the backend user',
)->addOption(
'password',
'p',
InputOption::VALUE_REQUIRED,
'The password of the backend user. See security note below.',
)->addOption(
'email',
'e',
InputOption::VALUE_REQUIRED,
'The email address of the backend user',
'',
)
->addOption(
'groups',
'g',
InputOption::VALUE_REQUIRED,
'Assign given groups to the user'
)
->addOption(
'language',
'l',
InputOption::VALUE_REQUIRED,
'The language for the user interface'
)
->addOption(
'admin',
'a',
InputOption::VALUE_NONE,
'Create user with admin privileges'
)->addOption(
'maintainer',
'm',
InputOption::VALUE_NONE,
'Create user with maintainer privileges',
)->setHelp(
<<<EOT
<fg=green>Create a backend user using environment variables</>
Example:
-------------------------------------------------
TYPO3_BE_USER_NAME=username \
TYPO3_BE_USER_EMAIL=admin@example.com \
TYPO3_BE_USER_GROUPS=<comma-separated-list-of-group-ids> \
TYPO3_BE_USER_LANGUAGE=de \
TYPO3_BE_USER_ADMIN=0 \
TYPO3_BE_USER_MAINTAINER=0 \
./bin/typo3 backend:user:create --no-interaction
-------------------------------------------------
<fg=yellow>
Variable "TYPO3_BE_USER_PASSWORD" and options "-p" or "--password" can be
used to provide a password. Using this can be a security risk since the password
may end up in shell history files. Prefer the interactive mode. Additionally,
writing a command to shell history can be suppressed by prefixing the command
with a space when using `bash` or `zsh`.
</>
EOT
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$input->setInteractive(!$input->getOption('no-interaction'));
/** @var QuestionHelper $questionHelper */
$questionHelper = $this->getHelper('question');
$username = $this->getUsername($questionHelper, $input, $output);
$password = $this->getPassword($questionHelper, $input, $output);
$email = $this->getEmail($questionHelper, $input, $output) ?: '';
$maintainer = $this->getMaintainer($questionHelper, $input, $output);
$language = $this->getLanguage($questionHelper, $input, $output) ?: 'en';
// If the user is 'maintainer' it is also required to set the 'admin' flag.
if ($maintainer) {
$admin = true;
} else {
$admin = $this->getAdmin($questionHelper, $input, $output);
}
// If 'admin' flag was set, this prompt is skipped.
// Because this user does already have access to the entire system.
if ($admin) {
$groups = [];
} else {
$groups = $this->getGroups($questionHelper, $input, $output);
}
$this->createUser($username, $password, $email, $admin, $maintainer, $groups, $language);
return Command::SUCCESS;
}
private function getUsername(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): string
{
// Taking deleted users into account as we want the username to be unique.
// So in case a user was deleted and will be restored, this could cause duplicated usernames.
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('be_users');
$queryBuilder->getRestrictions()->removeAll();
$usernames = $queryBuilder
->select('username')
->from('be_users')
->executeQuery()
->fetchFirstColumn();
$usernameValidator = static function ($username) use ($usernames) {
if (empty($username)) {
throw new \RuntimeException(
'Backend username must not be empty.',
1669822315,
);
}
if (in_array($username, $usernames, true)) {
throw new \RuntimeException(
'The username "' . $username . '" is already taken. Please use another username.',
1670797516,
);
}
return $username;
};
$usernameFromCli = $this->getFallbackValueEnvOrOption($input, 'username', 'TYPO3_BE_USER_NAME');
if ($usernameFromCli === false && $input->isInteractive()) {
$questionUsername = new Question('Enter the backend username of the new account: ');
$questionUsername->setValidator($usernameValidator);
return $questionHelper->ask($input, $output, $questionUsername);
}
return $usernameValidator($usernameFromCli);
}
private function getPassword(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): string
{
$passwordValidator = function ($password) {
$passwordValidationErrors = $this->getBackendUserPasswordValidationErrors((string)$password);
if (!empty($passwordValidationErrors)) {
throw new \RuntimeException(
'The given password is not secure enough!' . PHP_EOL
. ' * ' . implode(PHP_EOL . ' * ', $passwordValidationErrors),
1670267532,
);
}
return $password;
};
$passwordFromCli = $this->getFallbackValueEnvOrOption($input, 'password', 'TYPO3_BE_USER_PASSWORD');
// Force this question if no password set via cli.
// Thus, the user will always be prompted for a password even --no-interaction is set.
$currentlyInteractive = $input->isInteractive();
$input->setInteractive(true);
if ($passwordFromCli === false) {
$questionPassword = new Question('Enter a password for the backend user: ');
$questionPassword->setHidden(true);
$questionPassword->setHiddenFallback(false);
$questionPassword->setValidator($passwordValidator);
return $questionHelper->ask($input, $output, $questionPassword);
}
$input->setInteractive($currentlyInteractive);
return $passwordValidator($passwordFromCli);
}
private function getEmail(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): string
{
$emailValidator = static function ($email) {
if (!empty($email) && !GeneralUtility::validEmail($email)) {
throw new \RuntimeException(
'The given email is not valid! Please try again.',
1669813635,
);
}
return $email;
};
$emailFromCli = $this->getFallbackValueEnvOrOption($input, 'email', 'TYPO3_BE_USER_EMAIL');
if ($emailFromCli === false && $input->isInteractive()) {
$questionEmail = new Question('Enter the email for the backend user: ', '');
$questionEmail->setValidator($emailValidator);
return $questionHelper->ask($input, $output, $questionEmail);
}
return (string)$emailValidator($emailFromCli);
}
private function getGroups(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): array
{
$queryBuilder = $this->connectionPool->getConnectionForTable('be_groups');
$groupsList = $queryBuilder->select(['uid', 'title'], 'be_groups')->fetchAllAssociative();
$groupChoices = [];
foreach ($groupsList as $group) {
$groupChoices[$group['uid']] = $group['title'];
}
$groupValidator = static function ($groupList) use ($groupChoices) {
$groups = GeneralUtility::intExplode(',', $groupList ?: '');
foreach ($groups as $group) {
if (!empty($group) && !isset($groupChoices[$group])) {
throw new \RuntimeException(
'The given group uid "' . $group . '" does not exist.',
1670812929,
);
}
}
return $groups;
};
$groupsFromCli = $this->getFallbackValueEnvOrOption($input, 'groups', 'TYPO3_BE_USER_GROUPS');
if ($groupsFromCli === false && $input->isInteractive()) {
if (empty($groupChoices)) {
return [];
}
$questionGroups = new ChoiceQuestion('Select groups the newly created backend user should be assigned to (use comma-separated list for multiple groups): ', $groupChoices);
$questionGroups->setMultiselect(true);
$questionGroups->setValidator($groupValidator);
// Ensure keys are selected and not the values
$questionGroups->setAutocompleterValues(array_keys($groupChoices));
return $questionHelper->ask($input, $output, $questionGroups);
}
return $groupValidator($groupsFromCli);
}
private function getMaintainer(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): bool
{
$maintainerFromCli = $this->getFallbackValueEnvOrOption($input, 'maintainer', 'TYPO3_BE_USER_MAINTAINER');
if ($maintainerFromCli === false && $input->isInteractive()) {
$questionMaintainer = new ConfirmationQuestion('Create user with maintainer privileges [y/n default: n] ? ', false);
return (bool)$questionHelper->ask($input, $output, $questionMaintainer);
}
return (bool)$maintainerFromCli;
}
private function getAdmin(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): bool
{
$adminFromCli = $this->getFallbackValueEnvOrOption($input, 'admin', 'TYPO3_BE_USER_ADMIN');
if ($adminFromCli === false && $input->isInteractive()) {
$questionAdmin = new ConfirmationQuestion('Create user with admin privileges [y/n default: n] ? ', false);
return (bool)$questionHelper->ask($input, $output, $questionAdmin);
}
return (bool)$adminFromCli;
}
private function getLanguage(QuestionHelper $questionHelper, InputInterface $input, OutputInterface $output): string
{
$languagesList = $this->locales->getLanguages();
$languageValidator = static function ($language) use ($languagesList) {
if (!empty($language) && !isset($languagesList[$language])) {
throw new \RuntimeException(
'The given language "' . $language . '" is not supported.',
1769429507
);
}
return $language;
};
$languageFromCli = $this->getFallbackValueEnvOrOption($input, 'language', 'TYPO3_BE_USER_LANGUAGE');
if ($languageFromCli === false && $input->isInteractive()) {
$questionLanguage = new Question('Enter the language for the user interface [eg: de/fr/it/...]: ', '');
$questionLanguage->setValidator($languageValidator);
$questionLanguage->setAutocompleterValues(array_keys($languagesList));
return $questionHelper->ask($input, $output, $questionLanguage);
}
return (string)$languageValidator($languageFromCli);
}
/**
* Get a value from
* 1. environment variable
* 2. cli option
*/
private function getFallbackValueEnvOrOption(InputInterface $input, string $option, string $envVar): string|bool
{
$optionShortcut = $this->getDefinition()->getOption($option)->getShortcut();
$parameterOptions = ['--' . $option];
if ($optionShortcut !== null) {
$parameterOptions[] = '-' . $optionShortcut;
}
return $input->hasParameterOption($parameterOptions) ? $input->getOption($option) : getenv($envVar);
}
private function getBackendUserPasswordValidationErrors(string $password): array
{
$GLOBALS['LANG'] = $this->languageServiceFactory->create('en');
$passwordPolicy = $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordPolicy'] ?? 'default';
$passwordPolicyValidator = new PasswordPolicyValidator(
PasswordPolicyAction::NEW_USER_PASSWORD,
is_string($passwordPolicy) ? $passwordPolicy : ''
);
$contextData = new ContextData();
$passwordPolicyValidator->isValidPassword($password, $contextData);
return $passwordPolicyValidator->getValidationErrors();
}
/**
* Create a backend user.
* similar to "\TYPO3\CMS\Install\Service\SetupService::createUser()",
* but accepts admin/maintainer flag and groups
*/
private function createUser(string $username, string $password, string $email = '', bool $admin = false, bool $maintainer = false, array $groups = [], string $language = 'en'): void
{
// Initialize backend user authentication to ensure the new backend user can be created with proper permissions
Bootstrap::initializeBackendAuthentication();
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$backendUserId = StringUtility::getUniqueId('NEW');
$data = [
'be_users' => [
$backendUserId => [
'pid' => 0,
'username' => $username,
'password' => $password,
'email' => $email,
'admin' => $admin ? 1 : 0,
'usergroup' => $groups,
'disable' => 0,
'lang' => $language,
],
],
];
$dataHandler->start($data, []);
$dataHandler->process_datamap();
$backendUserId = $dataHandler->substNEWwithIDs[$backendUserId] ?? null;
if ($maintainer && $backendUserId) {
$maintainerIds = $this->configurationManager->getConfigurationValueByPath('SYS/systemMaintainers') ?? [];
sort($maintainerIds);
$maintainerIds[] = $backendUserId;
$this->configurationManager->setLocalConfigurationValuesByPathValuePairs([
'SYS/systemMaintainers' => array_unique($maintainerIds),
]);
}
}
}
@@ -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\Backend\Command;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Helper\TableSeparator;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Backend\Module\ModuleFactory;
use TYPO3\CMS\Backend\Module\ModuleInterface;
use TYPO3\CMS\Backend\Module\ModuleRegistry;
use TYPO3\CMS\Core\Attribute\AsNonSchedulableCommand;
use TYPO3\CMS\Core\Core\BootService;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Command for showing all backend modules and their associated labels
* @internal only for development purposes
*/
#[AsCommand('debug:backend:modules', 'Debugging: Show a list of the backend module tree (only for development purpose)')]
#[AsNonSchedulableCommand]
class DebugBackendModulesCommand extends Command
{
private LanguageService $languageService;
public function __construct(
private readonly ContainerInterface $failsafeContainer,
private readonly BootService $bootService,
private readonly ModuleFactory $moduleFactory,
private readonly LanguageServiceFactory $languageServiceFactory,
) {
$this->languageService = $GLOBALS['LANG'] = $this->languageServiceFactory->create('en');
// Note: We cannot directly use autowire of 'backend.modules' because that
// would only give us the final constructed registry, without access to data
// like "packageName" and "labels".
// @todo We should expose this data in our registry.
parent::__construct();
}
protected function configure(): void
{
$this
->addOption(
'csv-export',
'x',
InputOption::VALUE_NONE,
'Dump data as CSV (instead of CLI table)'
)
->addOption(
'core-only',
'c',
InputOption::VALUE_NONE,
'Only show core extensions'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$degraded = false;
try {
$container = $this->bootService->getContainer();
} catch (\Throwable $e) {
$container = $this->failsafeContainer;
$degraded = true;
}
$coreOnly = $input->getOption('core-only');
$title = 'Backend Modules';
if ($coreOnly) {
$title .= ' - Core';
}
if ($degraded) {
$title .= ' (failsafe)';
}
// We need this low-level access because 'packageName' and 'labels' cannot be retrieved by the Module Registry API (yet)
$modulesFromPackages = $container->get('backend.modules')->getArrayCopy();
$modulesFromPackages = $this->moduleFactory->adaptAliasMappingFromModuleConfiguration($modulesFromPackages);
$initializedModulesFromPackages = [];
foreach ($modulesFromPackages as $identifier => $configuration) {
if (!$coreOnly || str_starts_with($configuration['packageName'], 'typo3/cms-')) {
$initializedModulesFromPackages[$identifier] = $this->moduleFactory->createModule($identifier, $configuration);
}
}
$registry = GeneralUtility::makeInstance(ModuleRegistry::class, $initializedModulesFromPackages);
$modules = $registry->getModules();
$linearTree = [];
$headers = [
'Pkg',
'Main level',
'Second level',
'Third level',
'Position',
'Labels',
'Path',
];
$this->walkTree($modules, $linearTree, $modulesFromPackages);
if ($input->getOption('csv-export')) {
$out = fopen('php://output', 'w');
$separator = ';';
$enclosure = '"';
$escape = '\\';
$eol = PHP_EOL;
fputcsv($out, $headers, $separator, $enclosure, $escape, $eol);
foreach ($linearTree as $data) {
if ($data instanceof TableSeparator) {
$blankOutput = [];
foreach ($headers as $ignored) {
$blankOutput[] = '';
}
fputcsv($out, $blankOutput, $separator, $enclosure, $escape, $eol);
} else {
fputcsv($out, $data, $separator, $enclosure, $escape, $eol);
}
}
fclose($out);
} else {
$io->title($title);
$table = new Table($output);
$table->setHeaders($headers);
foreach ($linearTree as $data) {
$table->addRow($data);
}
$table->render();
}
return Command::SUCCESS;
}
/**
* @param ModuleInterface[] $modules
*/
private function walkTree(array $modules, array &$linearTree, array $modulesFromPackages, int $level = 1, array $parentStack = []): void
{
foreach ($modules as $module) {
// Main menus have no "parent". We only iterate these elements on the first level.
if ($level === 1 && $module->getParentIdentifier() !== '') {
continue;
}
$outputStack = $parentStack;
$outputStack[] = $module->getIdentifier();
$linearTree[] = [
$modulesFromPackages[$module->getIdentifier()]['packageName'],
$outputStack[0] ?? '',
$outputStack[1] ?? '',
$outputStack[2] ?? '',
($module->getPosition() !== [] ? json_encode($module->getPosition()) : ''),
$this->languageService->sL($module->getTitle()) . ' [' . $this->parseLabels($modulesFromPackages[$module->getIdentifier()]['labels']) . ']',
$module->getPath(),
];
// Next level
if ($module->hasSubModules()) {
$this->walkTree($module->getSubModules(), $linearTree, $modulesFromPackages, $level + 1, [...$parentStack, $module->getIdentifier()]);
}
if ($level === 1) {
$linearTree[] = new TableSeparator();
}
}
if ($level === 1) {
// Remove last separator
array_pop($linearTree);
}
}
private function parseLabels(array|string $labels): string
{
if (is_string($labels)) {
return $labels;
}
$out = "\n";
$out .= ' title: ' . ($labels['title'] ?? '-') . "\n";
$out .= ' shortDescription: ' . ($labels['shortDescription'] ?? '-') . "\n";
$out .= ' description: ' . ($labels['description'] ?? '-') . "\n";
return $out;
}
}
@@ -0,0 +1,238 @@
<?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\Backend\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Backend\Module\ModuleRegistry;
use TYPO3\CMS\Backend\Routing\Router;
use TYPO3\CMS\Core\Attribute\AsNonSchedulableCommand;
/**
* Debug backend routes, including module routes, AJAX routes and regular routes.
* Similar to Symfony's debug:router command.
* @internal only for development purposes
*/
#[AsCommand('debug:backend:routes', 'Debugging: List all registered backend routes (only for development purpose)')]
#[AsNonSchedulableCommand]
class DebugBackendRoutesCommand extends Command
{
public function __construct(
private readonly Router $router,
private readonly ModuleRegistry $moduleRegistry,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addOption(
'json',
null,
InputOption::VALUE_NONE,
'Output routes in JSON format'
)
->addOption(
'filter',
'f',
InputOption::VALUE_REQUIRED,
'Filter routes by name (supports partial matching)'
)
->addOption(
'limit',
'l',
InputOption::VALUE_REQUIRED,
'Limit routes by type: ajax, module, or route'
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$jsonOutput = $input->getOption('json');
$filter = $input->getOption('filter');
$limit = $input->getOption('limit');
// Validate limit option
if ($limit !== null) {
$limit = strtolower($limit);
if (!in_array($limit, ['ajax', 'module', 'route'], true)) {
$io->error('Invalid limit type. Valid options are: ajax, module, route');
return Command::FAILURE;
}
}
// Collect all routes
$routes = $this->collectAllRoutes($filter, $limit);
if (empty($routes)) {
$messages = [];
if ($filter) {
$messages[] = 'filter: ' . $filter;
}
if ($limit) {
$messages[] = 'type: ' . $limit;
}
if ($messages) {
$io->warning('No routes found matching ' . implode(', ', $messages));
} else {
$io->warning('No routes found.');
}
return Command::SUCCESS;
}
// Sort routes by name
ksort($routes);
if ($jsonOutput) {
$this->outputJson($routes, $output);
} else {
$this->outputTable($routes, $io, $output);
}
return Command::SUCCESS;
}
/**
* Collect all routes from Router (including AJAX routes) and Module routes
*
* @return array<string, array{name: string, method: string, path: string, target: string, type: string, options: array}>
*/
private function collectAllRoutes(?string $filter, ?string $limitType): array
{
$routes = [];
// Build a set of module identifiers for quick lookup
$moduleIdentifiers = [];
foreach ($this->moduleRegistry->getModules() as $module) {
if ($module->hasParentModule() || $module->isStandalone()) {
$moduleIdentifiers[$module->getIdentifier()] = true;
}
}
// Get all routes from Router (includes regular routes, AJAX routes, and module routes)
// Note: Routes can be either TYPO3 Route or Symfony Route objects
foreach ($this->router->getRoutes() as $routeName => $route) {
if ($filter && !str_contains((string)$routeName, $filter)) {
continue;
}
// Determine route type
$type = 'Route';
if (str_starts_with((string)$routeName, 'ajax_')) {
$type = 'Ajax';
} elseif (isset($moduleIdentifiers[(string)$routeName])) {
$type = 'Module';
}
// Apply limit filter if specified
if ($limitType !== null) {
$typeNormalized = strtolower($type);
if ($typeNormalized !== $limitType) {
continue;
}
}
// Get methods - works for both Symfony and TYPO3 Route objects
$methods = $route->getMethods();
$methodString = empty($methods) ? 'ANY' : implode('|', $methods);
// Get options - works for both route types
$options = method_exists($route, 'getOptions') ? $route->getOptions() : [];
$target = $options['target'] ?? $options['_controller'] ?? '-';
$routes[$routeName] = [
'name' => (string)$routeName,
'method' => $methodString,
'path' => $route->getPath(),
'target' => $target,
'type' => $type,
'options' => $options,
];
}
return $routes;
}
/**
* Output routes as JSON
*/
private function outputJson(array $routes, OutputInterface $output): void
{
$jsonData = [];
foreach ($routes as $route) {
$jsonData[] = [
'name' => $route['name'],
'method' => $route['method'],
'path' => $route['path'],
'target' => $route['target'],
'type' => $route['type'],
'options' => $route['options'],
];
}
$output->writeln((string)json_encode($jsonData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
/**
* Output routes as formatted table (similar to Symfony's debug:router)
*/
private function outputTable(array $routes, SymfonyStyle $io, OutputInterface $output): void
{
$table = new Table($output);
$table->setHeaders(['Name', 'Method', 'Path', 'Target', 'Type']);
$rows = [];
foreach ($routes as $route) {
$rows[] = [
$route['name'],
$route['method'],
$route['path'],
$this->formatTarget($route['target']),
$route['type'],
];
}
$table->setRows($rows);
$table->render();
$io->newLine();
$io->writeln(sprintf('<info>%d</info> routes found', count($routes)));
}
/**
* Format the target for display (shorten class names)
*/
private function formatTarget(string $target): string
{
// Shorten TYPO3 class names for better readability
$target = str_replace('TYPO3\\CMS\\', '', $target);
// Limit length if too long
if (strlen($target) > 80) {
return substr($target, 0, 77) . '...';
}
return $target;
}
}
+77
View File
@@ -0,0 +1,77 @@
<?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\Backend\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Backend\Authentication\BackendLocker;
/**
* Core function for locking the TYPO3 Backend
*/
#[AsCommand('backend:lock', 'Lock the TYPO3 Backend')]
class LockBackendCommand extends Command
{
public function __construct(protected readonly BackendLocker $lockService, ?string $name = null)
{
parent::__construct($name);
}
/**
* Configure the command by defining the name, options and arguments
*/
protected function configure(): void
{
$this
->addArgument(
'redirect',
InputArgument::OPTIONAL,
'If set, a locked TYPO3 Backend will redirect to URI specified with this argument. The URI is saved as a string in the lockfile that is specified in the system configuration.',
''
);
}
/**
* Executes the command for adding the lock file
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title($this->getDescription());
if ($this->lockService->isLocked()) {
$io->note('A lock file already exists. Overwriting it.');
}
$lockFile = $this->lockService->getAbsolutePathToLockFile();
$redirectUriFromLockFileContent = $input->getArgument('redirect');
if ($redirectUriFromLockFileContent) {
$redirectUriFromLockFileContent = is_string($redirectUriFromLockFileContent) ? $redirectUriFromLockFileContent : '';
}
if (!$this->lockService->lockBackend($redirectUriFromLockFileContent)) {
$io->error('Failed to create lock file "' . $lockFile . '".');
return Command::FAILURE;
}
$message = 'Wrote lock file to "' . $lockFile . '"';
if ($redirectUriFromLockFileContent !== '') {
$message .= LF . 'with target URI "' . $redirectUriFromLockFileContent . '".';
}
$io->success($message);
return Command::SUCCESS;
}
}
@@ -0,0 +1,116 @@
<?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\Backend\Command\ProgressListener;
use Psr\Log\LogLevel;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Backend\View\ProgressListenerInterface;
/**
* Shows the update for the reference index progress on the command line.
* @internal not part of TYPO3 Public API as it is an implementation of a concrete feature.
*/
class ReferenceIndexProgressListener implements ProgressListenerInterface
{
protected SymfonyStyle $io;
protected ?ProgressBar $progressBar = null;
protected bool $isEnabled = false;
public function initialize(SymfonyStyle $io)
{
$this->io = $io;
$this->isEnabled = $io->isQuiet() === false;
}
public function start(int $maxSteps = 0, ?string $additionalMessage = null): void
{
if (!$this->isEnabled) {
return;
}
$tableName = $additionalMessage;
if ($maxSteps > 0) {
$this->io->section('Update index of table ' . $tableName);
$this->progressBar = $this->io->createProgressBar($maxSteps);
$this->progressBar->start($maxSteps);
} else {
$this->io->section('Nothing to update for empty table ' . $tableName);
$this->progressBar = null;
}
}
public function advance(int $step = 1, ?string $additionalMessage = null): void
{
if (!$this->isEnabled) {
return;
}
if ($additionalMessage) {
$this->showMessageWhileInProgress(function () use ($additionalMessage) {
$this->io->writeln($additionalMessage);
});
}
if ($this->progressBar !== null) {
$this->progressBar->advance($step);
}
}
public function finish(?string $additionalMessage = null): void
{
if (!$this->isEnabled) {
return;
}
if ($this->progressBar !== null) {
$this->progressBar->finish();
$this->progressBar = null;
$this->io->writeln(PHP_EOL);
}
if ($additionalMessage) {
$this->io->writeln($additionalMessage);
}
}
public function log(string $message, string $logLevel = LogLevel::INFO): void
{
if (!$this->isEnabled) {
return;
}
$this->showMessageWhileInProgress(function () use ($message, $logLevel) {
switch ($logLevel) {
case LogLevel::ERROR:
$this->io->error($message);
break;
case LogLevel::WARNING:
$this->io->warning($message);
break;
default:
$this->io->writeln($message);
}
});
}
protected function showMessageWhileInProgress(callable $messageFunction): void
{
if ($this->progressBar !== null) {
$this->progressBar->clear();
$messageFunction();
$this->progressBar->display();
} else {
$messageFunction();
}
}
}
@@ -0,0 +1,69 @@
<?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\Backend\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Backend\Command\ProgressListener\ReferenceIndexProgressListener;
use TYPO3\CMS\Core\Core\Bootstrap;
use TYPO3\CMS\Core\Database\ReferenceIndex;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Core function to check/update the Reference Index
*/
#[AsCommand('referenceindex:update', 'Update the reference index of TYPO3')]
class ReferenceIndexUpdateCommand extends Command
{
/**
* Configure the command by defining the name, options and arguments
*/
protected function configure(): void
{
$this->addOption(
'check',
'c',
InputOption::VALUE_NONE,
'Only check the reference index of TYPO3'
);
}
/**
* Executes the command for adding or removing the lock file
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
Bootstrap::initializeBackendAuthentication();
$io = new SymfonyStyle($input, $output);
$isTestOnly = (bool)$input->getOption('check');
$progressListener = GeneralUtility::makeInstance(ReferenceIndexProgressListener::class);
$progressListener->initialize($io);
$referenceIndex = GeneralUtility::makeInstance(ReferenceIndex::class);
if ($isTestOnly) {
$io->section('Reference Index being TESTED (nothing written, remove the "--check" argument)');
} else {
$io->section('Reference Index is now being updated');
}
$referenceIndex->updateIndex($isTestOnly, $progressListener);
return Command::SUCCESS;
}
}
+152
View File
@@ -0,0 +1,152 @@
<?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\Backend\Command;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Backend\Authentication\PasswordReset;
use TYPO3\CMS\Core\Attribute\AsNonSchedulableCommand;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Core\SystemEnvironmentBuilder;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\Http\ServerRequest;
use TYPO3\CMS\Core\Http\Uri;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Triggers the workflow to request a new password for a user.
*/
#[AsCommand('backend:resetpassword', 'Trigger a password reset for a backend user.')]
#[AsNonSchedulableCommand]
class ResetPasswordCommand extends Command
{
public function __construct(private readonly Context $context, private readonly PasswordReset $passwordReset)
{
parent::__construct();
}
/**
* Configure the command by defining the name, options and arguments
*/
protected function configure(): void
{
$this
->addArgument(
'backendurl',
InputArgument::REQUIRED,
'The URL of the TYPO3 Backend, e.g. https://www.example.com/typo3/'
)->addArgument(
'email',
InputArgument::REQUIRED,
'The email address of a valid backend user'
);
}
/**
* Executes the command for sending out an email to reset the password.
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$email = $input->getArgument('email');
$email = is_string($email) ? $email : '';
if (!GeneralUtility::validEmail($email)) {
$io->error('The given email "' . $email . '" is not a valid email address.');
return Command::FAILURE;
}
$backendUrl = $input->getArgument('backendurl');
$backendUrl = is_string($backendUrl) ? $backendUrl : '';
if (!GeneralUtility::isValidUrl($backendUrl)) {
$io->error('The given backend URL "' . $backendUrl . '" is not a valid URL.');
return Command::FAILURE;
}
$request = $this->createFakeWebRequest($backendUrl);
$GLOBALS['TYPO3_REQUEST'] = $request;
$this->passwordReset->initiateReset($request, $this->context, $email);
$io->success('Password reset for email address "' . $email . '" initiated.');
return Command::SUCCESS;
}
/**
* This is needed to create a link to the backend properly.
*/
protected function createFakeWebRequest(string $backendUrl): ServerRequestInterface
{
$uri = new Uri($backendUrl);
$request = new ServerRequest(
$uri,
'GET',
'php://input',
[],
[
'HTTP_HOST' => $uri->getHost(),
'SERVER_NAME' => $uri->getHost(),
'HTTPS' => $uri->getScheme() === 'https',
'SCRIPT_FILENAME' => __FILE__,
'SCRIPT_NAME' => rtrim($uri->getPath(), '/') . '/',
]
);
$backedUpEnvironment = $this->simulateEnvironmentForBackendEntryPoint();
$normalizedParams = NormalizedParams::createFromRequest($request);
// Restore the environment
Environment::initialize(
Environment::getContext(),
Environment::isCli(),
Environment::isComposerMode(),
Environment::getProjectPath(),
Environment::getPublicPath(),
Environment::getVarPath(),
Environment::getConfigPath(),
$backedUpEnvironment['currentScript'],
Environment::isWindows() ? 'WINDOWS' : 'UNIX'
);
return $request
->withAttribute('normalizedParams', $normalizedParams)
->withAttribute('applicationType', SystemEnvironmentBuilder::REQUESTTYPE_BE);
}
/**
* This is a workaround to use "PublicPath . /typo3/index.php" instead of "publicPath . /typo3/sysext/core/bin/typo3"
* so the web root is detected properly in normalizedParams.
*/
protected function simulateEnvironmentForBackendEntryPoint(): array
{
$currentEnvironment = Environment::toArray();
Environment::initialize(
Environment::getContext(),
Environment::isCli(),
Environment::isComposerMode(),
Environment::getProjectPath(),
Environment::getPublicPath(),
Environment::getVarPath(),
Environment::getConfigPath(),
// This is ugly, as this change fakes the directory
dirname(Environment::getCurrentScript(), 4) . DIRECTORY_SEPARATOR . 'index.php',
Environment::isWindows() ? 'WINDOWS' : 'UNIX'
);
return $currentEnvironment;
}
}
+56
View File
@@ -0,0 +1,56 @@
<?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\Backend\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use TYPO3\CMS\Backend\Authentication\BackendLocker;
/**
* Core function for unlocking the TYPO3 Backend
*/
#[AsCommand('backend:unlock', 'Unlock the TYPO3 Backend')]
class UnlockBackendCommand extends Command
{
public function __construct(protected readonly BackendLocker $lockService, ?string $name = null)
{
parent::__construct($name);
}
/**
* Executes the command for removing the lock file
*/
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$io->title($this->getDescription());
$lockFile = $this->lockService->getAbsolutePathToLockFile();
if ($this->lockService->isLocked()) {
$this->lockService->unlock();
if ($this->lockService->isLocked()) {
$io->caution('Could not remove lock file "' . $lockFile . '"!');
return Command::FAILURE;
}
$io->success('Removed lock file "' . $lockFile . '".');
} else {
$io->note('No lock file "' . $lockFile . '" was found.' . LF . 'Hence no lock can be removed.');
}
return Command::SUCCESS;
}
}