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
+1
View File
@@ -0,0 +1 @@
/vendor/
+38
View File
@@ -0,0 +1,38 @@
<?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\Attribute;
/**
* Service tag to autoconfigure Backend avatar providers
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
final readonly class AsAvatarProvider
{
public const string TAG_NAME = 'backend.avatar_provider';
/**
* @param non-empty-string $identifier
* @param list<non-empty-string> $before
* @param list<non-empty-string> $after
*/
public function __construct(
public string $identifier,
public array $before = [],
public array $after = [],
) {}
}
+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\Backend\Attribute;
/**
* Service tag to autoconfigure Backend controllers
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class AsController
{
public const TAG_NAME = 'backend.controller';
public function __construct() {}
}
+40
View File
@@ -0,0 +1,40 @@
<?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\Attribute;
/**
* Service tag to autoconfigure backend sidebar components.
*
* @internal
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
final readonly class AsSidebarComponent
{
public const string TAG_NAME = 'backend.sidebar.component';
/**
* @param non-empty-string $identifier Unique identifier for the sidebar component
* @param list<non-empty-string> $before List of component identifiers, which should appear before
* @param list<non-empty-string> $after List of component identifiers, which should appear after
*/
public function __construct(
public string $identifier,
public array $before = [],
public array $after = [],
) {}
}
+81
View File
@@ -0,0 +1,81 @@
<?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\Authentication;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The TYPO3 Backend can be locked by creating a file named LOCK_BACKEND in a
* specified directory (TYPO3_CONF_VARS[BE][lockBackendFile]). The default is
* var/lock for composer-mode and config/ for legacy.
*
* This class encapsulates the logic to check for the existence of the lock file,
* thus nobody needs to know it's a file and where to put it outside of this class.
* It also enables future refactoring to support other means of backend un/locking.
*
* @internal
*/
class BackendLocker
{
public function isLocked(): bool
{
return @is_file($this->getAbsolutePathToLockFile());
}
public function lockBackend(string $redirectUriFromFileContent): bool
{
return GeneralUtility::writeFile($this->getAbsolutePathToLockFile(), $redirectUriFromFileContent, true);
}
public function unlock(): void
{
unlink($this->getAbsolutePathToLockFile());
}
public function getAbsolutePathToLockFile(): string
{
// This setting is empty by default to utilize the fallback storage location.
// If set specifically, this is the preference.
if (($GLOBALS['TYPO3_CONF_VARS']['BE']['lockBackendFile'] ?? '') !== '') {
return Environment::getProjectPath() . '/' . $GLOBALS['TYPO3_CONF_VARS']['BE']['lockBackendFile'];
}
return $this->getLockPath() . '/LOCK_BACKEND';
}
public function getRedirectUriFromLockContents(): string
{
return file_get_contents($this->getAbsolutePathToLockFile());
}
/**
* Based on composer or legacy mode, return a fallback directory
* location where LOCK_BACKEND can be stored.
* Composer-mode: "var/lock" is preferred because it is recommended to
* be shared and persist across deployments and the location
* is writable by the webserver)
* Legacy: "config/", because usually writable by webserver and persistent.
*/
protected function getLockPath(): string
{
return Environment::isComposerMode()
? Environment::getVarPath() . '/lock'
: Environment::getConfigPath();
}
}
@@ -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\Backend\Authentication\Event;
final readonly class PasswordHasBeenResetEvent
{
public function __construct(
public int $userId
) {}
}
@@ -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\Backend\Authentication\Event;
/**
* This event is triggered when a "SU" (switch user) action has been triggered
*/
final readonly class SwitchUserEvent
{
public function __construct(
private string $sessionId,
private array $targetUser,
private array $currentUser
) {}
public function getSessionId(): string
{
return $this->sessionId;
}
public function getTargetUser(): array
{
return $this->targetUser;
}
public function getCurrentUser(): array
{
return $this->currentUser;
}
}
+525
View File
@@ -0,0 +1,525 @@
<?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\Authentication;
use Doctrine\DBAL\Platforms\MariaDBPlatform as DoctrineMariaDBPlatform;
use Doctrine\DBAL\Platforms\MySQLPlatform as DoctrineMySQLPlatform;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\Mime\Address;
use TYPO3\CMS\Backend\Authentication\Event\PasswordHasBeenResetEvent;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Crypto\HashAlgo;
use TYPO3\CMS\Core\Crypto\HashService;
use TYPO3\CMS\Core\Crypto\PasswordHashing\PasswordHashFactory;
use TYPO3\CMS\Core\Crypto\Random;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\EndTimeRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\HiddenRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\RootLevelRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\StartTimeRestriction;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\Mail\MailerInterface;
use TYPO3\CMS\Core\Mail\TemplatedEmailFactory;
use TYPO3\CMS\Core\PasswordPolicy\Event\EnrichPasswordValidationContextDataEvent;
use TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyAction;
use TYPO3\CMS\Core\PasswordPolicy\PasswordPolicyValidator;
use TYPO3\CMS\Core\PasswordPolicy\Validator\Dto\ContextData;
use TYPO3\CMS\Core\RateLimiter\RateLimiterFactoryInterface;
use TYPO3\CMS\Core\Session\SessionManager;
use TYPO3\CMS\Core\SysLog\Action\Login as SystemLogLoginAction;
use TYPO3\CMS\Core\SysLog\Error as SystemLogErrorClassification;
use TYPO3\CMS\Core\SysLog\Type as SystemLogType;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This class is responsible for
* - find the right user, sending out a reset email.
* - create a token for creating the link (not exposed outside of this class)
* - validate a hashed token
* - send out an email to initiate the password reset
* - update a password for a backend user if all parameters match
*
* @internal this is a concrete implementation for User/Password login and not part of public TYPO3 Core API.
*/
#[Autoconfigure(public: true)]
readonly class PasswordReset
{
protected const TOKEN_VALID_UNTIL = '+2 hours';
public function __construct(
private LoggerInterface $logger,
private MailerInterface $mailer,
private TemplatedEmailFactory $templatedEmailFactory,
private HashService $hashService,
private Random $random,
private ConnectionPool $connectionPool,
private EventDispatcherInterface $eventDispatcher,
private PasswordHashFactory $passwordHashFactory,
private UriBuilder $uriBuilder,
private SessionManager $sessionManager,
private RateLimiterFactoryInterface $rateLimiterFactory,
) {}
/**
* Check if there are at least one in the system that contains a non-empty password AND an email address set.
*/
public function isEnabled(): bool
{
// Option not explicitly enabled
if (!($GLOBALS['TYPO3_CONF_VARS']['BE']['passwordReset'] ?? false)) {
return false;
}
$queryBuilder = $this->getPreparedQueryBuilder();
$statement = $queryBuilder
->select('uid')
->from('be_users')
->setMaxResults(1)
->executeQuery();
return (int)$statement->fetchOne() > 0;
}
/**
* Check if a specific backend user can be used to trigger an email reset for (email + password set)
*/
public function isEnabledForUser(int $userId): bool
{
$queryBuilder = $this->getPreparedQueryBuilder();
$statement = $queryBuilder
->select('uid')
->from('be_users')
->andWhere(
$queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($userId, Connection::PARAM_INT))
)
->setMaxResults(1)
->executeQuery();
return $statement->fetchOne() > 0;
}
/**
* Determine the right user and send out an email. If multiple users are found with the same email address
* an alternative email is sent.
*
* If no user is found, this is logged to the system (but not to sys_log).
*
* The method intentionally does not return anything to avoid any information disclosure or exposure.
*
* @param ServerRequestInterface $request
* @param Context $context
* @param string $emailAddress
*/
public function initiateReset(ServerRequestInterface $request, Context $context, string $emailAddress): void
{
if (!GeneralUtility::validEmail($emailAddress)) {
return;
}
if ($this->hasExceededMaximumAttemptsForReset($emailAddress)) {
$this->logger->alert('Password reset requested for email {email} but was requested too many times.', ['email' => $emailAddress]);
return;
}
$queryBuilder = $this->getPreparedQueryBuilder();
$users = $queryBuilder
->select('*')
->from('be_users')
->andWhere(
$queryBuilder->expr()->eq('email', $queryBuilder->createNamedParameter($emailAddress))
)
->executeQuery()
->fetchAllAssociative();
if ($users === []) {
// No user found, do nothing, also no log to sys_log in order avoid log flooding
$this->logger->warning('Password reset requested for email {email} but no valid users', ['email' => $emailAddress]);
} elseif (count($users) > 1) {
// More than one user with the same email address found, send out the email that one cannot send out a reset link
$this->sendAmbiguousEmail($request, $context, $emailAddress);
} else {
$user = reset($users);
unset($user['password']);
$this->sendResetEmail($request, $context, $user);
}
}
/**
* Send out an email to a given email address and note that a reset was triggered but email was used multiple times.
* Used when the database returned multiple users.
*/
protected function sendAmbiguousEmail(ServerRequestInterface $request, Context $context, string $emailAddress): void
{
$emailObject = $this->templatedEmailFactory->create($request)
->to(new Address($emailAddress))
->assign('email', $emailAddress)
->setTemplate('PasswordReset/AmbiguousResetRequested');
$this->mailer->send($emailObject);
$this->logger->warning('Password reset sent to email address {email} but multiple accounts found', ['email' => $emailAddress]);
$this->log(
'Sent password reset email to email address %s but with multiple accounts attached.',
SystemLogLoginAction::PASSWORD_RESET_REQUEST,
SystemLogErrorClassification::WARNING,
0,
[
'email' => $emailAddress,
],
NormalizedParams::createFromRequest($request)->getRemoteAddress(),
$context
);
}
/**
* Send out an email to a user that does have an email address added to his account, containing a reset link.
*/
protected function sendResetEmail(ServerRequestInterface $request, Context $context, array $user): void
{
$resetLink = $this->generateResetLinkForUser($context, (int)$user['uid'], (string)$user['email']);
$emailObject = $this->templatedEmailFactory->create($request)
->to(new Address((string)$user['email'], $user['realName']))
->assign('name', $user['realName'])
->assign('email', $user['email'])
->assign('language', $user['lang'] ?: 'en')
->assign('resetLink', $resetLink)
->assign('username', $user['username'])
->assign('userData', $user)
->setTemplate('PasswordReset/ResetRequested');
$this->mailer->send($emailObject);
$this->logger->info('Sent password reset email to email address {email} for user {username}', [
'email' => $user['email'],
'username' => $user['username'],
]);
$this->log(
'Sent password reset email to email address %s',
SystemLogLoginAction::PASSWORD_RESET_REQUEST,
SystemLogErrorClassification::SECURITY_NOTICE,
(int)$user['uid'],
[
'email' => $user['email'],
],
NormalizedParams::createFromRequest($request)->getRemoteAddress(),
$context
);
}
/**
* Creates a token, stores it in the database, and then creates an absolute URL for resetting the password.
* This is all in one method so it is not exposed from the outside.
*
* This function requires:
* a) the user is allowed to do a password reset (no check is done anymore)
* b) a valid email address.
*
* @param Context $context
* @param int $userId the backend user uid
* @param string $emailAddress is part of the hash to ensure that the email address does not get reset.
*/
protected function generateResetLinkForUser(Context $context, int $userId, string $emailAddress): UriInterface
{
$token = $this->random->generateRandomHexString(96);
$currentTime = $context->getAspect('date')->getDateTime();
$expiresOn = $currentTime->modify(self::TOKEN_VALID_UNTIL);
// Create a hash ("one time password") out of the token including the timestamp of the expiration date
$hash = $this->hashService->hmac($token . '|' . $expiresOn->getTimestamp() . '|' . $emailAddress . '|' . $userId, 'password-reset', HashAlgo::SHA3_256);
// Set the token in the database, which is hashed
$this->connectionPool
->getConnectionForTable('be_users')
->update(
'be_users',
['password_reset_token' => $this->passwordHashFactory->getDefaultHashInstance('BE')->getHashedPassword($hash)],
['uid' => $userId]
);
return $this->uriBuilder->buildUriFromRoute(
'password_reset_validate',
[
// "token"
't' => $token,
// "expiration date"
'e' => $expiresOn->getTimestamp(),
// "identity"
'i' => hash('sha1', $emailAddress . (string)$userId),
],
UriBuilder::ABSOLUTE_URL
);
}
/**
* Validates all query parameters / GET parameters of the given request against the token.
*/
public function isValidResetTokenFromRequest(ServerRequestInterface $request): bool
{
$user = $this->findValidUserForToken(
(string)($request->getQueryParams()['t'] ?? ''),
(string)($request->getQueryParams()['i'] ?? ''),
(int)($request->getQueryParams()['e'] ?? 0)
);
return $user !== null;
}
/**
* Fetch the user record from the database if the token is valid, and has matched all criteria
*
* @return array|null the BE User database record
*/
protected function findValidUserForToken(string $token, string $identity, int $expirationTimestamp): ?array
{
// Early return if token expired
if ($expirationTimestamp < time()) {
return null;
}
$user = null;
// Find the token in the database
$queryBuilder = $this->getPreparedQueryBuilder();
$queryBuilder
->select('uid', 'username', 'realName', 'email', 'password_reset_token', 'password')
->from('be_users');
$platform = $queryBuilder->getConnection()->getDatabasePlatform();
if ($platform instanceof DoctrineMariaDBPlatform || $platform instanceof DoctrineMySQLPlatform) {
$queryBuilder->andWhere(
$queryBuilder->expr()->comparison('SHA1(CONCAT(' . $queryBuilder->quoteIdentifier('email') . ', ' . $queryBuilder->quoteIdentifier('uid') . '))', $queryBuilder->expr()::EQ, $queryBuilder->createNamedParameter($identity))
);
$user = $queryBuilder->executeQuery()->fetchAssociative();
} else {
// no native SHA1/ CONCAT functionality, has to be done in PHP
$stmt = $queryBuilder->executeQuery();
while ($row = $stmt->fetchAssociative()) {
if (hash_equals(hash('sha1', $row['email'] . (string)$row['uid']), $identity)) {
$user = $row;
break;
}
}
}
if (!is_array($user) || empty($user)) {
return null;
}
// Validate hash by rebuilding the hash from the parameters and the URL and see if this matches against the stored password_reset_token
$hash = $this->hashService->hmac($token . '|' . $expirationTimestamp . '|' . $user['email'] . '|' . $user['uid'], 'password-reset', HashAlgo::SHA3_256);
if (!$this->passwordHashFactory->getDefaultHashInstance('BE')->checkPassword($hash, $user['password_reset_token'] ?? '')) {
return null;
}
return $user;
}
/**
* Update the password in the database if the password matches and the token is valid.
*
* @return bool whether the password was reset or not
*/
public function resetPassword(ServerRequestInterface $request, Context $context): bool
{
$expirationTimestamp = (int)($request->getQueryParams()['e'] ?? '');
$identityHash = (string)($request->getQueryParams()['i'] ?? '');
$token = (string)($request->getQueryParams()['t'] ?? '');
$newPassword = (string)($request->getParsedBody()['password'] ?? '');
$newPasswordRepeat = (string)($request->getParsedBody()['passwordrepeat'] ?? '');
$user = $this->findValidUserForToken($token, $identityHash, $expirationTimestamp);
if ($user === null) {
$this->logger->warning('Password reset not possible. Valid user for token not found.');
return false;
}
$userId = (int)$user['uid'];
if ($newPassword === '') {
$this->logger->debug('Password reset not possible because an empty password was provided.');
return false;
}
if ($newPassword !== $newPasswordRepeat) {
$this->logger->debug('Password reset not possible because new password and new password repeat do not match.');
return false;
}
if (!$this->isValidPassword($newPassword, $user)) {
$this->logger->debug('The new password does not match all requirements of the password policy.');
return false;
}
$this->connectionPool
->getConnectionForTable('be_users')
->update(
'be_users',
[
'password_reset_token' => '',
'password' => $this->passwordHashFactory->getDefaultHashInstance('BE')->getHashedPassword($newPassword),
],
['uid' => $userId]
);
$this->eventDispatcher->dispatch(new PasswordHasBeenResetEvent($userId));
$this->invalidateUserSessions($userId);
$this->logger->info('Password reset successful for user \'{username}\'', ['username' => $user['username'], 'user_id' => $userId]);
$this->log(
'Password reset successful for user %s',
SystemLogLoginAction::PASSWORD_RESET_ACCOMPLISHED,
SystemLogErrorClassification::SECURITY_NOTICE,
$userId,
[
'email' => $user['email'],
'user' => $userId,
],
NormalizedParams::createFromRequest($request)->getRemoteAddress(),
$context
);
return true;
}
/**
* The querybuilder for finding the right user - and adds some restrictions:
* - No CLI users
* - No Admin users (with option)
* - No hidden/deleted users
* - Password must be set
* - Username must be set
* - Email address must be set
*/
protected function getPreparedQueryBuilder(): QueryBuilder
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('be_users');
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(RootLevelRestriction::class))
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(StartTimeRestriction::class))
->add(GeneralUtility::makeInstance(EndTimeRestriction::class))
->add(GeneralUtility::makeInstance(HiddenRestriction::class));
$queryBuilder->where(
$queryBuilder->expr()->neq('username', $queryBuilder->createNamedParameter('')),
$queryBuilder->expr()->neq('username', $queryBuilder->createNamedParameter('_cli_')),
$queryBuilder->expr()->neq('password', $queryBuilder->createNamedParameter('')),
$queryBuilder->expr()->neq('email', $queryBuilder->createNamedParameter(''))
);
if (!($GLOBALS['TYPO3_CONF_VARS']['BE']['passwordResetForAdmins'] ?? false)) {
$queryBuilder->andWhere(
$queryBuilder->expr()->eq('admin', $queryBuilder->createNamedParameter(0, Connection::PARAM_INT))
);
}
return $queryBuilder;
}
/**
* Adds an entry to "sys_log", also used to track the maximum allowed attempts.
*
* @param string $message the information / message in english
* @param int $action see SystemLogLoginAction
* @param int $error see SystemLogErrorClassification
* @param array $data additional information, used for the message
* @param string $ipAddress
*/
protected function log(string $message, int $action, int $error, int $userId, array $data, $ipAddress, Context $context): void
{
$this->connectionPool
->getConnectionForTable('sys_log')
->insert(
'sys_log',
[
'userid' => $userId,
'type' => SystemLogType::LOGIN,
'channel' => SystemLogType::toChannel(SystemLogType::LOGIN),
'level' => SystemLogType::toLevel(SystemLogType::LOGIN),
'action' => $action,
'error' => $error,
'details' => $message,
'log_data' => json_encode($data),
'tablename' => 'be_users',
'recuid' => $userId,
'IP' => (string)$ipAddress,
'tstamp' => $context->getAspect('date')->get('timestamp'),
'event_pid' => 0,
'workspace' => 0,
],
[
Connection::PARAM_INT,
Connection::PARAM_INT,
Connection::PARAM_STR,
Connection::PARAM_STR,
Connection::PARAM_INT,
Connection::PARAM_INT,
Connection::PARAM_STR,
Connection::PARAM_STR,
Connection::PARAM_STR,
Connection::PARAM_INT,
Connection::PARAM_STR,
Connection::PARAM_INT,
Connection::PARAM_INT,
Connection::PARAM_INT,
]
);
}
/**
* Checks if an email reset link has been requested more than the configured amount of times.
* Default values are 3 times in the last 30 minutes configured in Services.yaml
*/
protected function hasExceededMaximumAttemptsForReset(string $email): bool
{
$limiter = $this->rateLimiterFactory->create($email);
$limit = $limiter->consume();
return !$limit->isAccepted();
}
/**
* Returns, if the given password is compliant with the global password policy for backend users
*/
protected function isValidPassword(string $password, array $user): bool
{
$passwordPolicy = $GLOBALS['TYPO3_CONF_VARS']['BE']['passwordPolicy'] ?? 'default';
$passwordPolicyValidator = GeneralUtility::makeInstance(
PasswordPolicyValidator::class,
PasswordPolicyAction::UPDATE_USER_PASSWORD,
is_string($passwordPolicy) ? $passwordPolicy : ''
);
$contextData = new ContextData(currentPasswordHash: $user['password']);
$contextData->setData('currentUsername', $user['username']);
$contextData->setData('currentFullname', $user['realName']);
$event = $this->eventDispatcher->dispatch(
new EnrichPasswordValidationContextDataEvent(
$contextData,
$user,
self::class
)
);
$contextData = $event->getContextData();
return $passwordPolicyValidator->isValidPassword($password, $contextData);
}
/**
* Invalidate all backend user sessions by given user id
*/
protected function invalidateUserSessions(int $userId): void
{
$this->sessionManager->invalidateAllSessionsByUserId(
$this->sessionManager->getSessionBackend('BE'),
$userId
);
}
}
+135
View File
@@ -0,0 +1,135 @@
<?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\Backend\Avatar;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Service\DependencyOrderingService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* Main class to render an avatar image of a certain Backend user, resolving any avatar provider
* that takes care of fetching the image.
*
* See render() and getImgTag() as main entry points
*/
#[Autoconfigure(public: true)]
readonly class Avatar
{
/**
* @param list<AvatarProviderInterface> $avatarProviders
*/
public function __construct(
#[Autowire(service: 'cache.runtime')]
protected FrontendInterface $cache,
protected DependencyOrderingService $dependencyOrderingService,
protected IconFactory $iconFactory,
protected array $avatarProviders = [],
) {
$this->validateAvatarProviders();
}
/**
* Renders an avatar based on a Fluid template which contains some base wrapper css classes.
* Has a simple caching functionality. Used in Avatar ViewHelper for instance.
* Renders avatar of a given backend user record, or of current logged-in backend user.
*/
public function render(?array $backendUser = null, int $size = 32, bool $showIcon = false): string
{
if (!is_array($backendUser)) {
/** @var array $backendUser */
$backendUser = $this->getBackendUser()->user;
}
$cacheId = 'avatar_' . sha1($backendUser['uid'] . $size . $showIcon);
$avatar = $this->cache->get($cacheId);
if (!$avatar) {
$icon = $showIcon ? $this->iconFactory->getIconForRecord('be_users', $backendUser, IconSize::SMALL)->render() : '';
$avatar
= '<span class="avatar" style="--avatar-size: ' . $size . 'px;">'
. '<span class="avatar-image">' . $this->getImgTag($backendUser, $size) . '</span>'
. ($showIcon ? '<span class="avatar-icon">' . $icon . '</span>' : '')
. '</span>';
$this->cache->set($cacheId, $avatar);
}
return $avatar;
}
/**
* Returns an HTML <img> tag of given backend users avatar.
*/
protected function getImgTag(array $backendUser, int $size = 32): string
{
$avatarImage = $this->getImage($backendUser, $size);
return '<img src="' . htmlspecialchars($avatarImage->getUrl()) . '" '
. 'width="' . (int)$avatarImage->getWidth() . '" '
. 'height="' . (int)$avatarImage->getHeight() . '" '
. 'alt="" '
. 'aria-hidden="true" '
. 'loading="lazy" />';
}
/**
* Get Image from first provider that returns one.
*/
protected function getImage(array $backendUser, int $size): Image
{
foreach ($this->avatarProviders as $provider) {
$avatarImage = $provider->getImage($backendUser, $size);
if (!empty($avatarImage)) {
return $avatarImage;
}
}
return GeneralUtility::makeInstance(
Image::class,
(string)PathUtility::getSystemResourceUri('EXT:core/Resources/Public/Icons/T3Icons/svgs/avatar/avatar-default.svg'),
$size,
$size
);
}
/**
* Validates the registered avatar providers
*
* @throws \RuntimeException
*/
protected function validateAvatarProviders(): void
{
foreach ($this->avatarProviders as $provider) {
if (!($provider instanceof AvatarProviderInterface)) {
throw new \RuntimeException(
sprintf(
'Avatar provider must implement interface "%s", "%s" given.',
AvatarProviderInterface::class,
get_debug_type($provider),
),
1439317802,
);
}
}
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,31 @@
<?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\Backend\Avatar;
/**
* Contract for avatar providers that ensure how an avatar should be rendered for a given Backend User
*/
interface AvatarProviderInterface
{
/**
* Returns an Image object, prepared for output, based on a given be_users record
*
* @param array $backendUser be_users record
* @param int $size
* @return Image|null
*/
public function getImage(array $backendUser, $size);
}
@@ -0,0 +1,105 @@
<?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\Backend\Avatar;
use TYPO3\CMS\Backend\Attribute\AsAvatarProvider;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Resource\Exception\FileDoesNotExistException;
use TYPO3\CMS\Core\Resource\ProcessedFile;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Avatar Provider used for rendering avatars based on local files (based on FAL), stored in the be_users.avatar
* relation field with sys_file_reference.
*/
#[AsAvatarProvider('defaultAvatarProvider')]
class DefaultAvatarProvider implements AvatarProviderInterface
{
/**
* Return an Image object for rendering the avatar, based on a FAL-based file
*
* @param array $backendUser be_users record
* @param int $size
* @return Image|null
*/
public function getImage(array $backendUser, $size)
{
$fileUid = $this->getAvatarFileUid($backendUser['uid']);
if ($fileUid === 0) {
// Early return if there is no valid image file UID
return null;
}
// Get file object
try {
$file = GeneralUtility::makeInstance(ResourceFactory::class)->getFileObject($fileUid);
$processedImage = $file->process(
ProcessedFile::CONTEXT_IMAGECROPSCALEMASK,
['width' => $size . 'c', 'height' => $size . 'c']
);
$publicUrl = $processedImage->getPublicUrl();
if ($publicUrl) {
$image = GeneralUtility::makeInstance(
Image::class,
$publicUrl,
$processedImage->getProperty('width'),
$processedImage->getProperty('height')
);
} else {
$image = null;
}
} catch (FileDoesNotExistException $e) {
// No image found
$image = null;
}
return $image;
}
/**
* Get the sys_file UID of the avatar of the given backend user ID
*
* @param int $backendUserId the UID of the be_users record
* @return int the sys_file UID or 0 if none found
*/
protected function getAvatarFileUid($backendUserId)
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('sys_file_reference');
$fileUid = $queryBuilder
->select('uid_local')
->from('sys_file_reference')
->where(
$queryBuilder->expr()->eq(
'tablenames',
$queryBuilder->createNamedParameter('be_users')
),
$queryBuilder->expr()->eq(
'fieldname',
$queryBuilder->createNamedParameter('avatar')
),
$queryBuilder->expr()->eq(
'uid_foreign',
$queryBuilder->createNamedParameter((int)$backendUserId, Connection::PARAM_INT)
)
)
->executeQuery()
->fetchOne();
return (int)$fileUid;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?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\Backend\Avatar;
/**
* Acts as a pseudo model for holding all information of an avatar image
* Holds url + dimensions of avatar image
*/
class Image
{
/**
* Url of avatar image. Needs to be relative to the website root or an absolute URL.
*
* @var string
*/
protected $url;
/**
* @var int
*/
protected $width;
/**
* @var int
*/
protected $height;
/**
* @param string $url url of image. Needs to be relative to the website root or an absolute URL.
* @param int $width width of image
* @param int $height height of image
*/
public function __construct($url, $width, $height)
{
$this->url = $url;
$this->width = (int)$width;
$this->height = (int)$height;
}
/**
* Fetches the URL to the avatar image
*
* @return string
*/
public function getUrl()
{
return $this->url;
}
/**
* @return int
*/
public function getWidth()
{
return $this->width;
}
/**
* @return int
*/
public function getHeight()
{
return $this->height;
}
}
+65
View File
@@ -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\Backend\Backend\Bookmark;
/**
* Value object representing a bookmark in the backend.
*
* Bookmarks provide quick access to frequently used backend locations
* and are displayed in the bookmark toolbar dropdown.
*
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
readonly class Bookmark implements \JsonSerializable
{
public function __construct(
public int $id,
public string $route,
public string $arguments,
public string $title,
public int|string $groupId,
public string $iconIdentifier,
public string $iconOverlayIdentifier,
public string $module,
public string $href,
public bool $editable = false,
public bool $accessible = false,
) {}
public function toArray(): array
{
return [
'id' => $this->id,
'route' => $this->route,
'arguments' => $this->arguments,
'title' => $this->title,
'groupId' => $this->groupId,
'iconIdentifier' => $this->iconIdentifier,
'iconOverlayIdentifier' => $this->iconOverlayIdentifier,
'module' => $this->module,
'href' => $this->href,
'editable' => $this->editable,
'accessible' => $this->accessible,
];
}
public function jsonSerialize(): array
{
return $this->toArray();
}
}
@@ -0,0 +1,55 @@
<?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\Backend\Bookmark;
/**
* Value object representing a bookmark group.
*
* Groups are used to organize bookmarks in the backend toolbar.
*
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
readonly class BookmarkGroup implements \JsonSerializable
{
public function __construct(
public int|string $id,
public string $label,
public BookmarkGroupType $type,
public int $sorting = 0,
public bool $editable = false,
public bool $selectable = true,
) {}
public function toArray(): array
{
return [
'id' => $this->id,
'label' => $this->label,
'type' => $this->type->value,
'priority' => $this->type->getPriority(),
'sorting' => $this->sorting,
'editable' => $this->editable,
'selectable' => $this->selectable,
];
}
public function jsonSerialize(): array
{
return $this->toArray();
}
}
@@ -0,0 +1,57 @@
<?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\Backend\Bookmark;
/**
* Defines the different types/sources of bookmark groups.
*
* @internal
*/
enum BookmarkGroupType: string
{
/**
* System groups defined via UserTSconfig (options.bookmarkGroups.X = "Label").
* These have positive integer IDs and include the default bookmark groups.
*/
case SYSTEM = 'system';
/**
* Global groups (negative IDs) - bookmarks visible to all users but only admins can add to them.
* These mirror the system groups but with negative IDs.
*/
case GLOBAL = 'global';
/**
* User-created groups stored in sys_be_shortcuts_group table.
* These have UUID identifiers and are specific to the user who created them.
*/
case USER = 'user';
/**
* Returns the priority for this group type.
* Lower values appear first: user → system → global
*/
public function getPriority(): int
{
return match ($this) {
self::USER => 0,
self::SYSTEM => 1,
self::GLOBAL => 2,
};
}
}
@@ -0,0 +1,432 @@
<?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\Backend\Bookmark;
use Symfony\Component\Uid\Uuid;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
/**
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
readonly class BookmarkRepository
{
protected const TABLE_NAME = 'sys_be_shortcuts';
protected const GROUP_TABLE_NAME = 'sys_be_shortcuts_group';
public function __construct(
protected ConnectionPool $connectionPool,
) {}
public function findById(int $id): ?array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$row = $queryBuilder->select('*')
->from(self::TABLE_NAME)
->where(
$queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($id, Connection::PARAM_INT)
)
)
->executeQuery()
->fetchAssociative();
return $row !== false ? $row : null;
}
/**
* @param list<int> $ids
* @return array<int, array> Indexed by bookmark ID
*/
public function findByIds(array $ids): array
{
if ($ids === []) {
return [];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$result = $queryBuilder->select('*')
->from(self::TABLE_NAME)
->where(
$queryBuilder->expr()->in(
'uid',
$queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
)
)
->executeQuery();
$bookmarks = [];
while ($row = $result->fetchAssociative()) {
$bookmarks[(int)$row['uid']] = $row;
}
return $bookmarks;
}
public function findByUser(int $userId): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$constraints = [];
// User's own bookmarks with a non-negative sc_group value.
// Bookmarks in user-created groups also match here, as their sc_group defaults to 0.
$constraints[] = $queryBuilder->expr()->and(
$queryBuilder->expr()->eq(
'userid',
$queryBuilder->createNamedParameter($userId, Connection::PARAM_INT)
),
$queryBuilder->expr()->gte(
'sc_group',
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
)
);
// Global bookmarks (negative sc_group) - visible to all users
$constraints[] = $queryBuilder->expr()->lt(
'sc_group',
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
);
$result = $queryBuilder->select('*')
->from(self::TABLE_NAME)
->where($queryBuilder->expr()->or(...$constraints))
->orderBy('sc_group')
->addOrderBy('sorting')
->executeQuery();
$bookmarks = [];
while ($row = $result->fetchAssociative()) {
$bookmarks[] = $row;
}
return $bookmarks;
}
public function exists(int $userId, string $routeIdentifier, string $arguments): bool
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->getRestrictions()->removeAll();
$uid = $queryBuilder->select('uid')
->from(self::TABLE_NAME)
->where(
$queryBuilder->expr()->eq(
'userid',
$queryBuilder->createNamedParameter($userId, Connection::PARAM_INT)
),
$queryBuilder->expr()->eq('route', $queryBuilder->createNamedParameter($routeIdentifier)),
$queryBuilder->expr()->eq('arguments', $queryBuilder->createNamedParameter($arguments))
)
->executeQuery()
->fetchOne();
return (bool)$uid;
}
/**
* @return int|false The new bookmark ID or false on failure
*/
public function insert(int $userId, string $routeIdentifier, string $arguments, string $title): int|false
{
$connection = $this->connectionPool->getConnectionForTable(self::TABLE_NAME);
$affectedRows = $connection->insert(
self::TABLE_NAME,
[
'userid' => $userId,
'route' => $routeIdentifier,
'arguments' => $arguments,
'description' => $title ?: 'Bookmark',
'sorting' => $GLOBALS['EXEC_TIME'],
]
);
if ($affectedRows === 1) {
return (int)$connection->lastInsertId();
}
return false;
}
public function update(
int $id,
?int $userId,
string $title,
int|string $groupId,
bool $allowGlobalGroups = true
): int {
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->update(self::TABLE_NAME)
->where(
$queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($id, Connection::PARAM_INT)
)
)
->set('description', $title);
if (is_string($groupId)) {
$queryBuilder->set('group_uuid', $groupId);
$queryBuilder->set('sc_group', BookmarkService::GROUP_DEFAULT);
} else {
$effectiveGroupId = $allowGlobalGroups ? $groupId : max(BookmarkService::GROUP_DEFAULT, $groupId);
$queryBuilder->set('sc_group', $effectiveGroupId);
$queryBuilder->set('group_uuid', null);
}
if ($userId !== null) {
$queryBuilder->andWhere(
$queryBuilder->expr()->eq(
'userid',
$queryBuilder->createNamedParameter($userId, Connection::PARAM_INT)
)
);
if (!$allowGlobalGroups) {
$queryBuilder->andWhere(
$queryBuilder->expr()->gte(
'sc_group',
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
)
);
}
}
return $queryBuilder->executeStatement();
}
public function delete(int $id, ?int $userId = null): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->delete(self::TABLE_NAME)
->where(
$queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($id, Connection::PARAM_INT)
)
);
if ($userId !== null) {
$queryBuilder->andWhere(
$queryBuilder->expr()->eq(
'userid',
$queryBuilder->createNamedParameter($userId, Connection::PARAM_INT)
)
);
}
return $queryBuilder->executeStatement();
}
/**
* @param array<int> $ids
*/
public function deleteMultiple(array $ids, int $userId): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
return $queryBuilder->delete(self::TABLE_NAME)
->where(
$queryBuilder->expr()->in(
'uid',
$queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
),
$queryBuilder->expr()->eq(
'userid',
$queryBuilder->createNamedParameter($userId, Connection::PARAM_INT)
)
)
->executeStatement();
}
public function updateSorting(int $id, int $userId, int $sorting): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
return $queryBuilder->update(self::TABLE_NAME)
->where(
$queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($id, Connection::PARAM_INT)
),
$queryBuilder->expr()->eq(
'userid',
$queryBuilder->createNamedParameter($userId, Connection::PARAM_INT)
)
)
->set('sorting', $sorting)
->executeStatement();
}
/**
* @param array<int> $ids
*/
public function moveToGroup(array $ids, int $userId, int|string $groupId, bool $allowGlobalGroups = true): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
$queryBuilder->update(self::TABLE_NAME)
->where(
$queryBuilder->expr()->in(
'uid',
$queryBuilder->createNamedParameter($ids, Connection::PARAM_INT_ARRAY)
),
$queryBuilder->expr()->eq(
'userid',
$queryBuilder->createNamedParameter($userId, Connection::PARAM_INT)
)
);
if (is_string($groupId)) {
$queryBuilder->set('group_uuid', $groupId);
$queryBuilder->set('sc_group', BookmarkService::GROUP_DEFAULT);
} else {
$effectiveGroupId = $allowGlobalGroups ? $groupId : max(BookmarkService::GROUP_DEFAULT, $groupId);
$queryBuilder->set('sc_group', $effectiveGroupId);
$queryBuilder->set('group_uuid', null);
}
return $queryBuilder->executeStatement();
}
public function findGroupsByUser(int $userId): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::GROUP_TABLE_NAME);
$result = $queryBuilder
->select('*')
->from(self::GROUP_TABLE_NAME)
->where(
$queryBuilder->expr()->eq('userid', $queryBuilder->createNamedParameter($userId, Connection::PARAM_INT))
)
->orderBy('sorting', 'ASC')
->executeQuery();
$groups = [];
while ($row = $result->fetchAssociative()) {
$groups[] = $row;
}
return $groups;
}
public function findGroupByUuid(string $uuid): ?array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::GROUP_TABLE_NAME);
$row = $queryBuilder
->select('*')
->from(self::GROUP_TABLE_NAME)
->where(
$queryBuilder->expr()->eq('uuid', $queryBuilder->createNamedParameter($uuid))
)
->executeQuery()
->fetchAssociative();
return $row !== false ? $row : null;
}
public function createGroup(int $userId, string $label): ?string
{
$uuid = (string)Uuid::v4();
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::GROUP_TABLE_NAME);
$maxSorting = $queryBuilder
->select('sorting')
->from(self::GROUP_TABLE_NAME)
->where(
$queryBuilder->expr()->eq('userid', $queryBuilder->createNamedParameter($userId, Connection::PARAM_INT))
)
->orderBy('sorting', 'DESC')
->setMaxResults(1)
->executeQuery()
->fetchOne();
$sorting = ($maxSorting !== false ? (int)$maxSorting : 0) + 1;
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::GROUP_TABLE_NAME);
$affectedRows = $queryBuilder
->insert(self::GROUP_TABLE_NAME)
->values([
'uuid' => $uuid,
'userid' => $userId,
'label' => $label,
'sorting' => $sorting,
])
->executeStatement();
return $affectedRows === 1 ? $uuid : null;
}
public function updateGroup(string $uuid, string $label): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::GROUP_TABLE_NAME);
return $queryBuilder
->update(self::GROUP_TABLE_NAME)
->where(
$queryBuilder->expr()->eq('uuid', $queryBuilder->createNamedParameter($uuid))
)
->set('label', $label)
->executeStatement();
}
public function deleteGroup(string $uuid): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::GROUP_TABLE_NAME);
return $queryBuilder
->delete(self::GROUP_TABLE_NAME)
->where(
$queryBuilder->expr()->eq('uuid', $queryBuilder->createNamedParameter($uuid))
)
->executeStatement();
}
/**
* @param list<string> $uuids
*/
public function reorderGroups(array $uuids, int $userId): void
{
$sorting = 0;
foreach ($uuids as $uuid) {
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::GROUP_TABLE_NAME);
$queryBuilder
->update(self::GROUP_TABLE_NAME)
->where(
$queryBuilder->expr()->eq('uuid', $queryBuilder->createNamedParameter($uuid)),
$queryBuilder->expr()->eq('userid', $queryBuilder->createNamedParameter($userId, Connection::PARAM_INT))
)
->set('sorting', $sorting++)
->executeStatement();
}
}
public function moveBookmarksFromGroupToDefault(string $uuid, int $userId): int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::TABLE_NAME);
return $queryBuilder
->update(self::TABLE_NAME)
->where(
$queryBuilder->expr()->eq('group_uuid', $queryBuilder->createNamedParameter($uuid)),
$queryBuilder->expr()->eq('userid', $queryBuilder->createNamedParameter($userId, Connection::PARAM_INT))
)
->set('group_uuid', null)
->set('sc_group', BookmarkService::GROUP_DEFAULT)
->executeStatement();
}
}
@@ -0,0 +1,622 @@
<?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\Backend\Bookmark;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Backend\Bookmark\Security\BookmarkGroupVoter;
use TYPO3\CMS\Backend\Backend\Bookmark\Security\BookmarkVoter;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Routing\Router;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
/**
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
#[Autoconfigure(public: true)]
readonly class BookmarkService
{
use Traits\RouteParserTrait;
public const GROUP_DEFAULT = 0;
public const GROUP_SUPERGLOBAL = -100;
public function __construct(
protected BookmarkRepository $bookmarkRepository,
protected BookmarkVoter $bookmarkVoter,
protected BookmarkGroupVoter $bookmarkGroupVoter,
protected IconFactory $iconFactory,
protected ModuleProvider $moduleProvider,
protected Router $router,
protected UriBuilder $uriBuilder,
) {}
protected function getRouter(): Router
{
return $this->router;
}
public function isEnabled(): bool
{
return (bool)($this->getBackendUser()->getTSConfig()['options.']['enableBookmarks'] ?? false);
}
/**
* @return Bookmark[]
*/
public function getBookmarks(): array
{
$backendUser = $this->getBackendUser();
$userId = (int)$backendUser->user['uid'];
$rows = $this->bookmarkRepository->findByUser($userId);
// Build list of valid group IDs from existing groups
$groups = $this->getGroups();
$validGroupIds = array_map(static fn(BookmarkGroup $g) => $g->id, $groups);
$bookmarks = [];
foreach ($rows as $row) {
// Skip bookmarks the user cannot read (e.g., global bookmarks they can't navigate to)
if (!$this->bookmarkVoter->vote(BookmarkVoter::READ, $row, $backendUser)) {
continue;
}
$row = $this->migrateBookmarkGroup($row, $validGroupIds);
$bookmark = $this->createBookmarkFromRow($row, $backendUser);
if ($bookmark !== null) {
$bookmarks[] = $bookmark;
}
}
return $bookmarks;
}
/**
* Ensures bookmark is assigned to a valid group for output.
*/
private function migrateBookmarkGroup(array $row, array $validGroupIds): array
{
$groupUuid = $row['group_uuid'] ?? null;
$scGroup = (int)($row['sc_group'] ?? 0);
$groupId = $groupUuid !== null ? $groupUuid : $scGroup;
if (in_array($groupId, $validGroupIds, true)) {
return $row;
}
$row['group_uuid'] = null;
$row['sc_group'] = $scGroup < 0 ? self::GROUP_SUPERGLOBAL : self::GROUP_DEFAULT;
return $row;
}
/**
* @return BookmarkGroup[]
*/
public function getGroups(): array
{
$groups = [];
$backendUser = $this->getBackendUser();
$languageService = $this->getLanguageService();
$globalPrefix = $languageService->sL('core.bookmarks:global');
// User-created groups
$userId = (int)$backendUser->user['uid'];
$userGroupRows = $this->bookmarkRepository->findGroupsByUser($userId);
foreach ($userGroupRows as $index => $row) {
$groups[] = $this->createBookmarkGroup(
$row['uuid'],
$row['label'] ?? '',
BookmarkGroupType::USER,
(int)($row['sorting'] ?? $index),
$backendUser,
isset($row['userid']) ? (int)$row['userid'] : null,
);
}
// TSconfig groups and their global counterparts
$configGroups = $backendUser->getTSConfig()['options.']['bookmarkGroups.'] ?? [];
$tsconfigIndex = 0;
if (is_array($configGroups)) {
foreach ($configGroups as $groupId => $configLabel) {
$groupId = (int)$groupId;
if ($groupId <= 0 || $groupId === 100 || $configLabel === '' || $configLabel === null) {
continue;
}
$label = $languageService->sL((string)$configLabel);
$sorting = $tsconfigIndex++;
$groups[] = $this->createBookmarkGroup(
$groupId,
$label,
BookmarkGroupType::SYSTEM,
$sorting,
$backendUser,
);
$groups[] = $this->createBookmarkGroup(
-$groupId,
$globalPrefix . ': ' . $label,
BookmarkGroupType::GLOBAL,
$sorting,
$backendUser,
);
}
}
// Superglobal group
$groups[] = $this->createBookmarkGroup(
self::GROUP_SUPERGLOBAL,
$globalPrefix . ': ' . $languageService->sL('core.bookmarks:all'),
BookmarkGroupType::GLOBAL,
0,
$backendUser,
);
// Default group
$groups[] = $this->createBookmarkGroup(
self::GROUP_DEFAULT,
$languageService->sL('core.bookmarks:group_default'),
BookmarkGroupType::SYSTEM,
100,
$backendUser,
);
// Sort by type priority, then by sorting value
$priorityKeys = array_map(static fn(BookmarkGroup $g) => $g->type->getPriority(), $groups);
$sortingKeys = array_map(static fn(BookmarkGroup $g) => $g->sorting, $groups);
array_multisort($priorityKeys, SORT_ASC, SORT_NUMERIC, $sortingKeys, SORT_ASC, SORT_NUMERIC, $groups);
return $groups;
}
public function getBookmark(int $id): ?Bookmark
{
$row = $this->bookmarkRepository->findById($id);
if ($row === null) {
return null;
}
$backendUser = $this->getBackendUser();
if (!$this->bookmarkVoter->vote(BookmarkVoter::READ, $row, $backendUser)) {
return null;
}
$groups = $this->getGroups();
$validGroupIds = array_map(static fn(BookmarkGroup $g) => $g->id, $groups);
$row = $this->migrateBookmarkGroup($row, $validGroupIds);
return $this->createBookmarkFromRow($row, $backendUser);
}
public function hasBookmark(string $routeIdentifier, string $arguments): bool
{
$userId = (int)$this->getBackendUser()->user['uid'];
return $this->bookmarkRepository->exists($userId, $routeIdentifier, $arguments);
}
/**
* @return int|false The ID of the newly created bookmark, or false on failure
*/
public function createBookmark(string $routeIdentifier, string $arguments = '', string $title = ''): int|false
{
$backendUser = $this->getBackendUser();
if (!$this->bookmarkVoter->vote(BookmarkVoter::CREATE, [], $backendUser)) {
return false;
}
if (!$this->router->hasRoute($routeIdentifier)) {
return false;
}
if ($arguments !== '' && !json_validate($arguments)) {
return false;
}
$userId = (int)$backendUser->user['uid'];
$result = $this->bookmarkRepository->insert($userId, $routeIdentifier, $arguments, $title);
return $result;
}
/**
* @return array{success: bool, error?: string}
*/
public function updateBookmark(int $id, string $title, int|string $groupId): array
{
$backendUser = $this->getBackendUser();
// Check if bookmark exists
$bookmark = $this->bookmarkRepository->findById($id);
if ($bookmark === null) {
return $this->errorResponse(
'core.bookmarks:error.notFound.message'
);
}
// Check edit permission
if (!$this->bookmarkVoter->vote(BookmarkVoter::EDIT, $bookmark, $backendUser)) {
return $this->errorResponse(
'core.bookmarks:error.accessDenied.message'
);
}
// Check if user can use global groups
if (is_int($groupId) && $groupId < 0 && !$backendUser->isAdmin()) {
return $this->errorResponse(
'core.bookmarks:error.globalGroupNotAllowed.message'
);
}
$userId = $backendUser->isAdmin() ? null : (int)$backendUser->user['uid'];
$allowGlobalGroups = $backendUser->isAdmin();
$this->bookmarkRepository->update(
$id,
$userId,
$title,
$groupId,
$allowGlobalGroups
);
return ['success' => true];
}
/**
* @return array{success: false, error: string}
*/
private function errorResponse(string $label): array
{
return [
'success' => false,
'error' => $this->getLanguageService()->sL($label),
];
}
/**
* @return array{success: bool, error?: string}
*/
public function deleteBookmark(int $id): array
{
$backendUser = $this->getBackendUser();
$bookmark = $this->bookmarkRepository->findById($id);
if ($bookmark === null) {
return $this->errorResponse(
'core.bookmarks:error.notFound.message'
);
}
if (!$this->bookmarkVoter->vote(BookmarkVoter::DELETE, $bookmark, $backendUser)) {
return $this->errorResponse(
'core.bookmarks:error.accessDenied.message'
);
}
$affectedRows = $this->bookmarkRepository->delete($id);
if ($affectedRows !== 1) {
return $this->errorResponse(
'core.bookmarks:error.deleteFailed.message'
);
}
return ['success' => true];
}
/**
* @param int[] $bookmarkIds
*/
public function reorderBookmarks(array $bookmarkIds): bool
{
$backendUser = $this->getBackendUser();
$userId = (int)$backendUser->user['uid'];
// Fetch all bookmarks in one query
$bookmarks = $this->bookmarkRepository->findByIds($bookmarkIds);
$sorting = 0;
foreach ($bookmarkIds as $bookmarkId) {
$bookmark = $bookmarks[$bookmarkId] ?? null;
if ($bookmark !== null && $this->bookmarkVoter->vote(BookmarkVoter::EDIT, $bookmark, $backendUser)) {
$this->bookmarkRepository->updateSorting($bookmarkId, $userId, $sorting++);
}
}
return true;
}
/**
* @param int[] $bookmarkIds
*/
public function deleteBookmarks(array $bookmarkIds): bool
{
$backendUser = $this->getBackendUser();
$userId = (int)$backendUser->user['uid'];
// Fetch all bookmarks in one query
$bookmarks = $this->bookmarkRepository->findByIds($bookmarkIds);
$manageableIds = [];
foreach ($bookmarkIds as $bookmarkId) {
$bookmark = $bookmarks[$bookmarkId] ?? null;
if ($bookmark !== null && $this->bookmarkVoter->vote(BookmarkVoter::DELETE, $bookmark, $backendUser)) {
$manageableIds[] = $bookmarkId;
}
}
if ($manageableIds !== []) {
$this->bookmarkRepository->deleteMultiple($manageableIds, $userId);
}
return true;
}
/**
* @param int[] $bookmarkIds
*/
public function moveBookmarks(array $bookmarkIds, int|string $groupId): bool
{
$backendUser = $this->getBackendUser();
$userId = (int)$backendUser->user['uid'];
$allowGlobalGroups = $backendUser->isAdmin();
// Validate target group access for user-created groups
if (is_string($groupId)) {
$group = $this->bookmarkRepository->findGroupByUuid($groupId);
if ($group === null || !$this->bookmarkGroupVoter->vote(BookmarkGroupVoter::READ, $group, $backendUser)) {
return false;
}
}
// Fetch all bookmarks in one query and filter to those user can edit
$bookmarks = $this->bookmarkRepository->findByIds($bookmarkIds);
$manageableIds = [];
foreach ($bookmarkIds as $bookmarkId) {
$bookmark = $bookmarks[$bookmarkId] ?? null;
if ($bookmark !== null && $this->bookmarkVoter->vote(BookmarkVoter::EDIT, $bookmark, $backendUser)) {
$manageableIds[] = $bookmarkId;
}
}
if ($manageableIds === []) {
return false;
}
$this->bookmarkRepository->moveToGroup(
$manageableIds,
$userId,
$groupId,
$allowGlobalGroups
);
return true;
}
public function createGroup(string $label): ?BookmarkGroup
{
$backendUser = $this->getBackendUser();
if (!$this->bookmarkGroupVoter->vote(BookmarkGroupVoter::CREATE, [], $backendUser)) {
return null;
}
$userId = (int)$backendUser->user['uid'];
$uuid = $this->bookmarkRepository->createGroup($userId, $label);
if ($uuid === null) {
return null;
}
$row = $this->bookmarkRepository->findGroupByUuid($uuid);
if ($row === null) {
return null;
}
return $this->createBookmarkGroup(
$row['uuid'],
$row['label'] ?? '',
BookmarkGroupType::USER,
0,
$backendUser,
isset($row['userid']) ? (int)$row['userid'] : null,
);
}
public function updateGroup(string $uuid, string $label): bool
{
$backendUser = $this->getBackendUser();
$group = $this->bookmarkRepository->findGroupByUuid($uuid);
if ($group === null || !$this->bookmarkGroupVoter->vote(BookmarkGroupVoter::EDIT, $group, $backendUser)) {
return false;
}
$this->bookmarkRepository->updateGroup($uuid, $label);
return true;
}
public function deleteGroup(string $uuid): bool
{
$backendUser = $this->getBackendUser();
$group = $this->bookmarkRepository->findGroupByUuid($uuid);
if ($group === null || !$this->bookmarkGroupVoter->vote(BookmarkGroupVoter::DELETE, $group, $backendUser)) {
return false;
}
$userId = (int)$backendUser->user['uid'];
$this->bookmarkRepository->moveBookmarksFromGroupToDefault($uuid, $userId);
$affectedRows = $this->bookmarkRepository->deleteGroup($uuid);
return $affectedRows > 0;
}
/**
* @param string[] $uuids
*/
public function reorderGroups(array $uuids): bool
{
$backendUser = $this->getBackendUser();
$userId = (int)$backendUser->user['uid'];
// Get all user's groups in one query
$userGroups = $this->bookmarkRepository->findGroupsByUser($userId);
$groupsByUuid = [];
foreach ($userGroups as $group) {
$groupsByUuid[$group['uuid']] = $group;
}
// Verify all provided UUIDs can be edited by the user
foreach ($uuids as $uuid) {
$group = $groupsByUuid[$uuid] ?? null;
if ($group === null || !$this->bookmarkGroupVoter->vote(BookmarkGroupVoter::EDIT, $group, $backendUser)) {
return false;
}
}
$this->bookmarkRepository->reorderGroups($uuids, $userId);
return true;
}
private function createBookmarkFromRow(array $row, BackendUserAuthentication $user): ?Bookmark
{
$routeIdentifier = $row['route'] ?? '';
try {
$arguments = json_decode($row['arguments'] ?? '', true, 64, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
if (!is_array($arguments)) {
return null;
}
$moduleName = $this->getModuleNameFromRouteIdentifier($routeIdentifier);
if ($moduleName === '') {
return null;
}
$accessible = $this->bookmarkVoter->vote(BookmarkVoter::NAVIGATE, $row, $user);
$editable = $this->bookmarkVoter->vote(BookmarkVoter::EDIT, $row, $user);
$groupUuid = $row['group_uuid'] ?? null;
$groupId = $groupUuid !== null ? $groupUuid : (int)$row['sc_group'];
$bookmarkData = $this->parseRecordEditData($routeIdentifier, $arguments);
$iconData = $this->resolveBookmarkIcon($routeIdentifier, $moduleName, $bookmarkData);
$href = $accessible ? (string)$this->uriBuilder->buildUriFromRoute($routeIdentifier, $arguments) : '';
return new Bookmark(
id: (int)$row['uid'],
route: $routeIdentifier,
arguments: $row['arguments'] ?? '',
title: ($row['description'] ?? false) ?: 'Bookmark',
groupId: $groupId,
iconIdentifier: $iconData['identifier'],
iconOverlayIdentifier: $iconData['overlay'],
module: $moduleName,
href: $href,
editable: $editable,
accessible: $accessible,
);
}
/**
* @return array{identifier: string, overlay: string}
*/
private function resolveBookmarkIcon(string $routeIdentifier, string $moduleName, array $bookmarkData): array
{
$identifier = '';
$overlay = '';
switch ($routeIdentifier) {
case 'record_edit':
$table = $bookmarkData['table'] ?? '';
$recordid = $bookmarkData['recordid'] ?? 0;
$action = $bookmarkData['action'] ?? '';
if ($action === 'edit') {
$row = BackendUtility::getRecordWSOL($table, (int)$recordid) ?? [];
$icon = $this->iconFactory->getIconForRecord($table, $row, IconSize::SMALL);
} elseif ($action === 'new') {
$icon = $this->iconFactory->getIconForRecord($table, [], IconSize::SMALL);
} else {
$icon = $this->iconFactory->getIcon('empty-empty', IconSize::SMALL);
}
$identifier = $icon->getIdentifier();
$overlay = $icon->getOverlayIcon()?->getIdentifier() ?? '';
break;
case 'file_edit':
$identifier = 'mimetypes-text-html';
break;
default:
$iconIdentifier = '';
if ($module = $this->moduleProvider->getModule($moduleName, null, false)) {
$iconIdentifier = $module->getIconIdentifier();
if ($iconIdentifier === '' && $module->getParentModule()) {
$iconIdentifier = $module->getParentModule()->getIconIdentifier();
}
}
if ($iconIdentifier === '') {
$iconIdentifier = 'empty-empty';
}
$identifier = $iconIdentifier;
}
return [
'identifier' => $identifier,
'overlay' => $overlay,
];
}
private function createBookmarkGroup(
int|string $id,
string $label,
BookmarkGroupType $type,
int $sorting,
BackendUserAuthentication $user,
?int $userid = null
): BookmarkGroup {
$voterData = ['id' => $id, 'userid' => $userid];
$editable = $this->bookmarkGroupVoter->vote(BookmarkGroupVoter::EDIT, $voterData, $user);
$selectable = $this->bookmarkGroupVoter->vote(BookmarkGroupVoter::SELECT, $voterData, $user);
return new BookmarkGroup(
id: $id,
label: $label,
type: $type,
sorting: $sorting,
editable: $editable,
selectable: $selectable,
);
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,93 @@
<?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\Backend\Bookmark\Security;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
/**
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
#[Autoconfigure(public: true)]
final readonly class BookmarkGroupVoter
{
public const string READ = 'read';
public const string CREATE = 'create';
public const string EDIT = 'edit';
public const string DELETE = 'delete';
public const string SELECT = 'select';
public function vote(string $attribute, array $group, BackendUserAuthentication $user): bool
{
return match ($attribute) {
self::READ => $this->canRead($group, $user),
self::CREATE => $this->canCreate($user),
self::EDIT => $this->canEdit($group, $user),
self::DELETE => $this->canDelete($group, $user),
self::SELECT => $this->canSelect($group, $user),
default => false,
};
}
private function canCreate(BackendUserAuthentication $user): bool
{
return isset($user->user['uid']) && (int)$user->user['uid'] > 0;
}
private function canRead(array $group, BackendUserAuthentication $user): bool
{
$userId = (int)$user->user['uid'];
return (int)$group['userid'] === $userId;
}
private function canEdit(array $group, BackendUserAuthentication $user): bool
{
$groupId = $group['id'] ?? null;
// System/global groups (integer IDs) are not editable
if (is_int($groupId) || is_numeric($groupId)) {
return false;
}
// User-created groups (UUID strings) are editable only if user owns them
if (!isset($group['userid'])) {
return false;
}
$userId = (int)$user->user['uid'];
return (int)$group['userid'] === $userId;
}
private function canDelete(array $group, BackendUserAuthentication $user): bool
{
// Delete permission mirrors edit permission
return $this->canEdit($group, $user);
}
private function canSelect(array $group, BackendUserAuthentication $user): bool
{
$groupId = $group['id'] ?? 0;
// Global groups (negative IDs) are only selectable by admins
if (is_int($groupId) && $groupId < 0) {
return $user->isAdmin();
}
return true;
}
}
@@ -0,0 +1,253 @@
<?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\Backend\Bookmark\Security;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Backend\Bookmark\Traits\RouteParserTrait;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Routing\Router;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Resource\Exception\FolderDoesNotExistException;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException;
use TYPO3\CMS\Core\Resource\StorageRepository;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
/**
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
#[Autoconfigure(public: true)]
final readonly class BookmarkVoter
{
use RouteParserTrait;
public const string READ = 'read';
public const string CREATE = 'create';
public const string EDIT = 'edit';
public const string DELETE = 'delete';
public const string NAVIGATE = 'navigate';
public function __construct(
protected ModuleProvider $moduleProvider,
protected Router $router,
protected LoggerInterface $logger,
protected StorageRepository $storageRepository,
) {}
protected function getRouter(): Router
{
return $this->router;
}
public function vote(string $attribute, array $bookmark, BackendUserAuthentication $user): bool
{
return match ($attribute) {
self::READ => $this->canRead($bookmark, $user),
self::CREATE => $this->canCreate($user),
self::EDIT => $this->canEdit($bookmark, $user),
self::DELETE => $this->canDelete($bookmark, $user),
self::NAVIGATE => $this->canNavigate($bookmark, $user),
default => false,
};
}
private function canCreate(BackendUserAuthentication $user): bool
{
return isset($user->user['uid']) && (int)$user->user['uid'] > 0;
}
private function canRead(array $bookmark, BackendUserAuthentication $user): bool
{
$userId = (int)$user->user['uid'];
// User's own bookmarks are always readable
if ((int)$bookmark['userid'] === $userId) {
return true;
}
// Global bookmarks are only readable if user can navigate to them
if ((int)$bookmark['sc_group'] < 0) {
return $this->canNavigate($bookmark, $user);
}
return false;
}
private function canEdit(array $bookmark, BackendUserAuthentication $user): bool
{
if (!$this->canRead($bookmark, $user)) {
return false;
}
$userId = (int)$user->user['uid'];
$isOwner = (int)$bookmark['userid'] === $userId;
$groupId = isset($bookmark['group_uuid']) && $bookmark['group_uuid'] !== '' ? $bookmark['group_uuid'] : (int)$bookmark['sc_group'];
$isGlobal = is_int($groupId) && $groupId < 0;
if ($isOwner && !$isGlobal) {
return true;
}
if ($user->isAdmin() && $isGlobal) {
return true;
}
return false;
}
private function canDelete(array $bookmark, BackendUserAuthentication $user): bool
{
$userId = (int)$user->user['uid'];
if ((int)$bookmark['userid'] === $userId) {
return true;
}
if ($user->isAdmin() && (int)$bookmark['sc_group'] < 0) {
return true;
}
return false;
}
private function canNavigate(array $bookmark, BackendUserAuthentication $user): bool
{
$routeIdentifier = $bookmark['route'] ?? '';
try {
$arguments = json_decode($bookmark['arguments'] ?? '', true, 64, JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return false;
}
if (!is_array($arguments)) {
return false;
}
$moduleName = $this->getModuleNameFromRouteIdentifier($routeIdentifier);
if ($moduleName === '') {
return false;
}
if (!$this->canAccessModule($routeIdentifier, $moduleName, $user)) {
return false;
}
if (!$this->canAccessFile($moduleName, $arguments)) {
return false;
}
if (!$this->canAccessRecord($moduleName, $routeIdentifier, $arguments, $user)) {
return false;
}
return true;
}
private function canAccessModule(string $routeIdentifier, string $moduleName, BackendUserAuthentication $user): bool
{
// record_edit has its own access checks via canAccessRecord
if ($routeIdentifier === 'record_edit') {
return true;
}
return $this->moduleProvider->accessGranted($moduleName, $user);
}
private function canAccessFile(string $moduleName, array $arguments): bool
{
if ($moduleName !== 'file_FilelistList' && $moduleName !== 'media_management') {
return true;
}
$combinedIdentifier = (string)($arguments['id'] ?? '');
if ($combinedIdentifier === '') {
return true;
}
$storage = $this->storageRepository->findByCombinedIdentifier($combinedIdentifier);
if ($storage === null || $storage->isFallbackStorage()) {
return false;
}
$folderIdentifier = substr($combinedIdentifier, strpos($combinedIdentifier, ':') + 1);
try {
$storage->getFolder($folderIdentifier);
} catch (InsufficientFolderAccessPermissionsException) {
return false;
} catch (FolderDoesNotExistException) {
return true;
} catch (\Throwable $e) {
$this->logger->error('Failed to resolve folder identifier "{folder}" in backend user bookmark: {message}', [
'folder' => $folderIdentifier,
'message' => $e->getMessage(),
]);
return false;
}
return true;
}
private function canAccessRecord(
string $moduleName,
string $routeIdentifier,
array $arguments,
BackendUserAuthentication $user
): bool {
if ($moduleName === 'file_FilelistList' || $moduleName === 'media_management') {
return true;
}
$bookmarkData = $this->parseRecordEditData($routeIdentifier, $arguments);
$pageId = 0;
if ($moduleName === 'record_edit' && isset($bookmarkData['table'], $bookmarkData['recordid'])) {
if (!$user->check('tables_modify', $bookmarkData['table'])) {
return false;
}
$action = $bookmarkData['action'] ?? '';
$recordId = (int)$bookmarkData['recordid'];
if ($action === 'edit' || ($action === 'new' && $recordId < 0)) {
$record = BackendUtility::getRecord($bookmarkData['table'], abs($recordId));
if ($record === null || $record === []) {
return false;
}
$pageId = ($bookmarkData['table'] === 'pages' ? (int)($record['uid'] ?? 0) : (int)($record['pid'] ?? 0));
} elseif ($action === 'new' && $recordId > 0) {
$pageId = $recordId;
}
} else {
$pageId = (int)($arguments['id'] ?? 0);
}
if ($pageId > 0 && !$user->isAdmin()) {
if ($user->isInWebMount($pageId) === null) {
return false;
}
$pageRow = BackendUtility::getRecord('pages', $pageId);
if ($pageRow === null || !$user->doesUserHaveAccess($pageRow, Permission::PAGE_SHOW)) {
return false;
}
}
return true;
}
}
@@ -0,0 +1,67 @@
<?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\Backend\Bookmark\Traits;
use TYPO3\CMS\Backend\Routing\Router;
/**
* Shared route parsing functionality for bookmark components.
*
* @internal
*/
trait RouteParserTrait
{
abstract protected function getRouter(): Router;
private function getModuleNameFromRouteIdentifier(string $routeIdentifier): string
{
if (in_array($routeIdentifier, ['record_edit', 'file_edit'], true)) {
return $routeIdentifier;
}
return (string)($this->getRouter()->getRoute($routeIdentifier)?->getOption('module')?->getIdentifier() ?? '');
}
/**
* @return array{table?: string, recordid?: string, action?: string}
*/
private function parseRecordEditData(string $routeIdentifier, array $arguments): array
{
if ($routeIdentifier !== 'record_edit' || !is_array($arguments['edit'] ?? null)) {
return [];
}
$table = key($arguments['edit']);
$tableData = current($arguments['edit']);
$recordId = is_array($tableData) ? key($tableData) : null;
if (!is_string($table) || (!is_string($recordId) && !is_int($recordId))) {
return [];
}
$recordId = (string)$recordId;
if (str_ends_with($recordId, ',')) {
$recordId = substr($recordId, 0, -1);
}
return [
'table' => $table,
'recordid' => $recordId,
'action' => $arguments['edit'][$table][$recordId] ?? '',
];
}
}
+52
View File
@@ -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\Backend\Backend;
enum ColorScheme: string
{
case auto = 'auto';
case light = 'light';
case dark = 'dark';
public function getLabel(): string
{
return match ($this) {
self::auto => 'LLL:EXT:backend/Resources/Private/Language/locallang.xlf:colorScheme.auto',
self::light => 'LLL:EXT:backend/Resources/Private/Language/locallang.xlf:colorScheme.light',
self::dark => 'LLL:EXT:backend/Resources/Private/Language/locallang.xlf:colorScheme.dark',
};
}
public function getIcon(): string
{
return match ($this) {
self::auto => 'actions-circle-half',
self::light => 'actions-brightness-high',
self::dark => 'actions-moon',
};
}
public static function getAvailableItemsForSelection(): array
{
return [
['label' => self::auto->getLabel(), 'value' => self::auto->value],
['label' => self::light->getLabel(), 'value' => self::light->value],
['label' => self::dark->getLabel(), 'value' => self::dark->value],
];
}
}
@@ -0,0 +1,82 @@
<?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\Backend\Event;
use TYPO3\CMS\Backend\Backend\ToolbarItems\ClearCacheToolbarItem;
/**
* An event to modify the clear cache actions, shown in the TYPO3 Backend top toolbar
*
* @phpstan-import-type CacheAction from ClearCacheToolbarItem
*/
final class ModifyClearCacheActionsEvent
{
/**
* @param list<CacheAction> $cacheActions
* @param list<non-empty-string> $cacheActionIdentifiers
*/
public function __construct(private array $cacheActions, private array $cacheActionIdentifiers) {}
/**
* @param CacheAction $cacheAction
*/
public function addCacheAction(array $cacheAction): void
{
$this->cacheActions[] = $cacheAction;
}
/**
* @param list<CacheAction> $cacheActions
*/
public function setCacheActions(array $cacheActions): void
{
$this->cacheActions = $cacheActions;
}
/**
* @return list<CacheAction>
*/
public function getCacheActions(): array
{
return $this->cacheActions;
}
/**
* @param non-empty-string $cacheActionIdentifier
*/
public function addCacheActionIdentifier(string $cacheActionIdentifier): void
{
$this->cacheActionIdentifiers[] = $cacheActionIdentifier;
}
/**
* @param list<non-empty-string> $cacheActionIdentifiers
*/
public function setCacheActionIdentifiers(array $cacheActionIdentifiers): void
{
$this->cacheActionIdentifiers = $cacheActionIdentifiers;
}
/**
* @return list<non-empty-string>
*/
public function getCacheActionIdentifiers(): array
{
return $this->cacheActionIdentifiers;
}
}
@@ -0,0 +1,34 @@
<?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\Backend\Event;
use TYPO3\CMS\Backend\Backend\ToolbarItems\SystemInformationToolbarItem;
/**
* An event to enrich the system information toolbar in the TYPO3 Backend top toolbar
* with various information
*/
final readonly class SystemInformationToolbarCollectorEvent
{
public function __construct(private SystemInformationToolbarItem $toolbarItem) {}
public function getToolbarItem(): SystemInformationToolbarItem
{
return $this->toolbarItem;
}
}
+39
View File
@@ -0,0 +1,39 @@
<?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\Backend;
/**
* @internal This is a concrete controller implementation and is not part of TYPO3 Core API.
*/
enum QrCodeSize: string
{
case SMALL = 'small';
case MEDIUM = 'medium';
case LARGE = 'large';
case MEGA = 'mega';
public function getSize(): int
{
return match ($this) {
self::SMALL => 64,
self::MEDIUM => 128,
self::LARGE => 256,
self::MEGA => 512,
};
}
}
+48
View File
@@ -0,0 +1,48 @@
<?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\Backend;
/**
* @internal
*/
enum ThumbnailSize: string
{
case DEFAULT = 'default';
case SMALL = 'small';
case MEDIUM = 'medium';
case LARGE = 'large';
public function getDimensions(): array
{
return array_map(static fn(int $size) => $size . 'm', $this->getBaseDimensions());
}
public function getCroppedDimensions(): array
{
return array_map(static fn(int $size) => $size . 'c', $this->getBaseDimensions());
}
private function getBaseDimensions(): array
{
return match ($this) {
self::DEFAULT, self::SMALL => [32, 32],
self::MEDIUM => [64, 64],
self::LARGE => [96, 96],
};
}
}
@@ -0,0 +1,97 @@
<?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\Backend\ToolbarItems;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Backend\Bookmark\BookmarkService;
use TYPO3\CMS\Backend\Toolbar\RequestAwareToolbarItemInterface;
use TYPO3\CMS\Backend\Toolbar\ToolbarItemInterface;
use TYPO3\CMS\Backend\View\BackendViewFactory;
/**
* Class to render the bookmark menu toolbar.
*
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
#[Autoconfigure(public: true)]
class BookmarkToolbarItem implements ToolbarItemInterface, RequestAwareToolbarItemInterface
{
private ServerRequestInterface $request;
public function __construct(
private readonly BookmarkService $bookmarkService,
private readonly BackendViewFactory $backendViewFactory,
) {}
public function setRequest(ServerRequestInterface $request): void
{
$this->request = $request;
}
/**
* Checks whether the user has access to this toolbar item.
*/
public function checkAccess(): bool
{
return $this->bookmarkService->isEnabled();
}
/**
* Render bookmark icon.
*/
public function getItem(): string
{
$view = $this->backendViewFactory->create($this->request);
return $view->render('ToolbarItems/BookmarkToolbarItemItem');
}
/**
* This item has a drop-down.
*/
public function hasDropDown(): bool
{
return true;
}
/**
* Render drop-down content.
* The dropdown contains a custom element that fetches data via AJAX.
*/
public function getDropDown(): string
{
$view = $this->backendViewFactory->create($this->request);
return $view->render('ToolbarItems/BookmarkToolbarItemDropDown');
}
/**
* This toolbar item needs no additional attributes.
*/
public function getAdditionalAttributes(): array
{
return [];
}
/**
* Position relative to others.
*/
public function getIndex(): int
{
return 40;
}
}
@@ -0,0 +1,182 @@
<?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\Backend\ToolbarItems;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Backend\Event\ModifyClearCacheActionsEvent;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Toolbar\RequestAwareToolbarItemInterface;
use TYPO3\CMS\Backend\Toolbar\ToolbarItemInterface;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
/**
* Render cache clearing toolbar item.
* Adds a dropdown if there are more than one item to clear (usually for admins to render the flush all caches).
* The dropdown items can be manipulated using ModifyClearCacheActionsEvent.
*
* @phpstan-type CacheAction array{
* id: non-empty-string,
* endpoint: non-empty-string,
* iconIdentifier: non-empty-string,
* title: non-empty-string,
* description?: non-empty-string,
* severity?: 'notice'|'info'|'succcess'|'warning'|'error'|'danger',
* }
*/
#[Autoconfigure(public: true)]
class ClearCacheToolbarItem implements ToolbarItemInterface, RequestAwareToolbarItemInterface
{
/**
* @var list<CacheAction>
*/
protected array $cacheActions = [];
/**
* @var list<non-empty-string>
*/
protected array $optionValues = [];
private ServerRequestInterface $request;
public function __construct(
UriBuilder $uriBuilder,
EventDispatcherInterface $eventDispatcher,
private readonly BackendViewFactory $backendViewFactory,
) {
$isAdmin = $this->getBackendUser()->isAdmin();
$userTsConfig = $this->getBackendUser()->getTSConfig();
// Clear all page-related caches
if ($isAdmin || ($userTsConfig['options.']['clearCache.']['pages'] ?? false)) {
$this->cacheActions[] = [
'id' => 'pages',
'title' => 'core.cache:group.pages.label',
'description' => 'core.cache:group.pages.description',
'endpoint' => (string)$uriBuilder->buildUriFromRoute('ajax_clearcache_group_pages'),
'severity' => 'success',
'iconIdentifier' => 'actions-bolt-alt',
];
$this->optionValues[] = 'pages';
}
// Clearing of all caches is only shown if explicitly enabled via TSConfig
// or if BE-User is admin and the TSconfig explicitly disables the possibility for admins.
// This is useful for big production systems where admins accidentally could slow down the system.
if (($userTsConfig['options.']['clearCache.']['all'] ?? false)
|| ($isAdmin && (bool)($userTsConfig['options.']['clearCache.']['all'] ?? true))
) {
$this->cacheActions[] = [
'id' => 'all',
'title' => 'core.cache:group.all.label',
'description' => 'core.cache:group.all.description',
'endpoint' => (string)$uriBuilder->buildUriFromRoute('ajax_clearcache_group_all'),
'severity' => 'danger',
'iconIdentifier' => 'actions-bolt-alt',
];
$this->optionValues[] = 'all';
}
$event = new ModifyClearCacheActionsEvent($this->cacheActions, $this->optionValues);
$event = $eventDispatcher->dispatch($event);
$this->cacheActions = $event->getCacheActions();
$this->optionValues = $event->getCacheActionIdentifiers();
}
public function setRequest(ServerRequestInterface $request): void
{
$this->request = $request;
}
/**
* Checks whether the user has access to this toolbar item.
*/
public function checkAccess(): bool
{
$backendUser = $this->getBackendUser();
if ($backendUser->isAdmin()) {
return true;
}
foreach ($this->optionValues as $value) {
if ($backendUser->getTSConfig()['options.']['clearCache.'][$value] ?? false) {
return true;
}
}
return false;
}
/**
* Render clear cache icon, based on the option if there is more than one icon or just one.
*/
public function getItem(): string
{
$view = $this->backendViewFactory->create($this->request);
if ($this->hasDropDown()) {
return $view->render('ToolbarItems/ClearCacheToolbarItem');
}
$cacheAction = end($this->cacheActions);
$view->assignMultiple([
'endpoint' => $cacheAction['endpoint'],
'title' => $cacheAction['title'],
'iconIdentifier' => $cacheAction['iconIdentifier'],
]);
return $view->render('ToolbarItems/ClearCacheToolbarItemSingle');
}
/**
* Render drop-down.
*/
public function getDropDown(): string
{
$view = $this->backendViewFactory->create($this->request);
$view->assign('cacheActions', $this->cacheActions);
return $view->render('ToolbarItems/ClearCacheToolbarItemDropDown');
}
/**
* No additional attributes needed.
*/
public function getAdditionalAttributes(): array
{
return [];
}
/**
* This item has a drop-down, if there is more than one cache action available for the current Backend user.
*/
public function hasDropDown(): bool
{
return count($this->cacheActions) > 1;
}
/**
* Position relative to others
*/
public function getIndex(): int
{
return 20;
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,98 @@
<?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\Backend\ToolbarItems;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Toolbar\RequestAwareToolbarItemInterface;
use TYPO3\CMS\Backend\Toolbar\ToolbarItemInterface;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
/**
* Adds backend live search to the toolbar by adding JavaScript and adding an input search field
*/
class LiveSearchToolbarItem implements ToolbarItemInterface, RequestAwareToolbarItemInterface
{
private ServerRequestInterface $request;
public function __construct(
private readonly ModuleProvider $moduleProvider,
private readonly BackendViewFactory $backendViewFactory,
) {}
public function setRequest(ServerRequestInterface $request): void
{
$this->request = $request;
}
/**
* Checks whether the user has access to this toolbar item.
* Live search depends on the records module and only available when that module is allowed.
*/
public function checkAccess(): bool
{
return $this->moduleProvider->accessGranted('records', $this->getBackendUser());
}
/**
* Render search field.
*/
public function getItem(): string
{
$view = $this->backendViewFactory->create($this->request);
return $view->render('ToolbarItems/LiveSearchToolbarItem');
}
/**
* This item needs additional attributes.
*/
public function getAdditionalAttributes(): array
{
return ['class' => 't3js-toolbar-item-search'];
}
/**
* This item has no drop-down.
*/
public function hasDropDown(): bool
{
return false;
}
/**
* No drop-down here.
*/
public function getDropDown(): string
{
return '';
}
/**
* Position relative to others, live search should be very right.
*/
public function getIndex(): int
{
return 10;
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,352 @@
<?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\Backend\ToolbarItems;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\Backend\Event\SystemInformationToolbarCollectorEvent;
use TYPO3\CMS\Backend\Toolbar\InformationStatus;
use TYPO3\CMS\Backend\Toolbar\RequestAwareToolbarItemInterface;
use TYPO3\CMS\Backend\Toolbar\ToolbarItemInterface;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Information\Typo3Version;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\CommandUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Render system information toolbar item and drop-down.
* Provides some events for other extensions to add information.
*/
#[Autoconfigure(public: true)]
class SystemInformationToolbarItem implements ToolbarItemInterface, RequestAwareToolbarItemInterface
{
private ServerRequestInterface $request;
protected array $systemInformation = [];
protected InformationStatus $highestSeverity;
protected string $severityBadgeClass = '';
protected array $systemMessages = [];
protected int $systemMessageTotalCount = 0;
public function __construct(
private readonly EventDispatcherInterface $eventDispatcher,
private readonly Typo3Version $typo3Version,
private readonly BackendViewFactory $backendViewFactory,
) {
$this->highestSeverity = InformationStatus::INFO;
}
public function setRequest(ServerRequestInterface $request): void
{
$this->request = $request;
}
/**
* Add a system message.
* This is a callback method for signal receivers.
*
* @param string $text The text to be displayed
* @param InformationStatus $status The status of this system message
* @param int $count Will be added to the total count
* @param string $module The associated module
* @param string $params Query string with additional parameters
*/
public function addSystemMessage($text, InformationStatus $status = InformationStatus::OK, $count = 0, $module = '', $params = ''): void
{
$this->systemMessageTotalCount += $count;
// define the severity for the badge
if ($status->isGreaterThan($this->highestSeverity)) {
$this->highestSeverity = $status;
}
$this->systemMessages[] = [
'module' => $module,
'params' => $params,
'count' => $count,
'status' => $status->value,
'text' => $text,
];
}
/**
* Add a system information.
* This is a callback method for signal receivers.
*
* @param string $title The title of this system information, typically a LLL:EXT:... label string
* @param string $value The associated value
* @param string $iconIdentifier The icon identifier
* @param InformationStatus $status The status of this system information
*/
public function addSystemInformation($title, $value, $iconIdentifier, InformationStatus $status = InformationStatus::NOTICE): void
{
$this->systemInformation[] = [
'title' => $title,
'value' => $value,
'iconIdentifier' => $iconIdentifier,
'status' => $status->value,
];
}
/**
* Checks whether the user has access to this toolbar item.
*/
public function checkAccess(): bool
{
return $this->getBackendUserAuthentication()->isAdmin();
}
/**
* Render system information dropdown.
*/
public function getItem(): string
{
$view = $this->backendViewFactory->create($this->request);
return $view->render('ToolbarItems/SystemInformationToolbarItem');
}
/**
* Render drop-down
*/
public function getDropDown(): string
{
if (!$this->checkAccess()) {
return '';
}
$this->collectInformation();
$view = $this->backendViewFactory->create($this->request);
$view->assignMultiple([
'messages' => $this->systemMessages,
'count' => $this->systemMessageTotalCount > 99 ? '99+' : $this->systemMessageTotalCount,
'severityBadgeClass' => $this->severityBadgeClass,
'systemInformation' => $this->systemInformation,
]);
return $view->render('ToolbarItems/SystemInformationDropDown');
}
/**
* No additional attributes needed.
*/
public function getAdditionalAttributes(): array
{
return [];
}
/**
* This item has a drop-down.
*/
public function hasDropDown(): bool
{
return true;
}
/**
* Position relative to others
*/
public function getIndex(): int
{
return 30;
}
/**
* Collect the information for the drop-down.
*/
protected function collectInformation(): void
{
$this->addTypo3Version();
$this->addInstallationMode();
$this->addWebServer();
$this->addPhpVersion();
$this->addDebugger();
$this->addDatabase();
$this->addApplicationContext();
$this->addGitRevision();
$this->addOperatingSystem();
$this->eventDispatcher->dispatch(new SystemInformationToolbarCollectorEvent($this));
$this->severityBadgeClass = $this->highestSeverity !== InformationStatus::NOTICE ? 'badge-' . $this->highestSeverity->value : '';
}
protected function addTypo3Version(): void
{
$this->systemInformation[] = [
'title' => 'LLL:EXT:backend/Resources/Private/Language/locallang_toolbar.xlf:toolbarItems.sysinfo.typo3-version',
'value' => $this->typo3Version->getVersion(),
'iconIdentifier' => 'information-typo3-version',
];
}
protected function addInstallationMode(): void
{
$this->systemInformation[] = [
'title' => 'LLL:EXT:backend/Resources/Private/Language/locallang_toolbar.xlf:toolbarItems.sysinfo.installationMethod',
'value' => Environment::isComposerMode()
? $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_toolbar.xlf:toolbarItems.sysinfo.installationMethod.composer')
: $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_toolbar.xlf:toolbarItems.sysinfo.installationMethod.classic'),
'iconIdentifier' => 'actions-package',
];
}
protected function addWebServer(): void
{
$this->systemInformation[] = [
'title' => 'LLL:EXT:backend/Resources/Private/Language/locallang_toolbar.xlf:toolbarItems.sysinfo.webserver',
'value' => $_SERVER['SERVER_SOFTWARE'] ?? '',
'iconIdentifier' => 'information-webserver',
];
}
protected function addPhpVersion(): void
{
$this->systemInformation[] = [
'title' => 'LLL:EXT:backend/Resources/Private/Language/locallang_toolbar.xlf:toolbarItems.sysinfo.phpversion',
'value' => PHP_VERSION,
'iconIdentifier' => 'information-php-version',
];
}
protected function addDebugger(): void
{
$knownDebuggers = ['xdebug', 'Zend Debugger'];
foreach ($knownDebuggers as $debugger) {
if (extension_loaded($debugger)) {
$debuggerVersion = phpversion($debugger) ?: '';
$this->systemInformation[] = [
'title' => 'LLL:EXT:backend/Resources/Private/Language/locallang_toolbar.xlf:toolbarItems.sysinfo.debugger',
'value' => sprintf('%s %s', $debugger, $debuggerVersion),
'iconIdentifier' => 'information-debugger',
];
}
}
}
protected function addDatabase(): void
{
foreach (GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionNames() as $connectionName) {
$serverVersion = '[' . $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_toolbar.xlf:toolbarItems.sysinfo.database.offline') . ']';
$success = true;
try {
$serverVersion = GeneralUtility::makeInstance(ConnectionPool::class)
->getConnectionByName($connectionName)
->getPlatformServerVersion();
} catch (\Exception $exception) {
$success = false;
}
$this->systemInformation[] = [
'title' => 'LLL:EXT:backend/Resources/Private/Language/locallang_toolbar.xlf:toolbarItems.sysinfo.database',
'titleAddition' => $connectionName,
'value' => $serverVersion,
'status' => $success ? InformationStatus::NOTICE->value : InformationStatus::ERROR->value,
'iconIdentifier' => 'information-database',
];
}
}
protected function addApplicationContext(): void
{
$applicationContext = Environment::getContext();
$this->systemInformation[] = [
'title' => 'LLL:EXT:backend/Resources/Private/Language/locallang_toolbar.xlf:toolbarItems.sysinfo.applicationcontext',
'value' => (string)$applicationContext,
'status' => $applicationContext->isProduction() ? InformationStatus::NOTICE->value : InformationStatus::WARNING->value,
'iconIdentifier' => 'information-application-context',
];
}
/**
* Gets the current GIT revision and branch
*/
protected function addGitRevision(): void
{
if (!str_ends_with($this->typo3Version->getVersion(), '-dev') || $this->isFunctionDisabled('exec')) {
return;
}
// check if git exists
$returnCode = 0;
CommandUtility::exec('git --version', $_, $returnCode);
if ($returnCode !== 0) {
// git is not available
return;
}
$revision = CommandUtility::exec('git rev-parse --short HEAD');
$branch = CommandUtility::exec('git rev-parse --abbrev-ref HEAD');
if ($revision === false || $branch === false) {
return;
}
$revision = trim($revision);
$branch = trim($branch);
if ($revision !== '' && $branch !== '') {
$this->systemInformation[] = [
'title' => 'LLL:EXT:backend/Resources/Private/Language/locallang_toolbar.xlf:toolbarItems.sysinfo.gitrevision',
'value' => sprintf('%s [%s]', $revision, $branch),
'iconIdentifier' => 'information-git',
];
}
}
/**
* Gets the system kernel and version
*/
protected function addOperatingSystem(): void
{
switch (PHP_OS_FAMILY) {
case 'Linux':
$icon = 'linux';
break;
case 'Darwin':
$icon = 'apple';
break;
case 'Windows':
$icon = 'windows';
break;
default:
$icon = 'unknown';
}
$this->systemInformation[] = [
'title' => 'LLL:EXT:backend/Resources/Private/Language/locallang_toolbar.xlf:toolbarItems.sysinfo.operatingsystem',
'value' => PHP_OS . ' ' . php_uname('r'),
'iconIdentifier' => 'information-os-' . $icon,
];
}
/**
* Check if the given PHP function is disabled in the system.
*/
protected function isFunctionDisabled(string $functionName): bool
{
$disabledFunctions = GeneralUtility::trimExplode(',', (string)ini_get('disable_functions'));
if (!empty($disabledFunctions)) {
return in_array($functionName, $disabledFunctions, true);
}
return false;
}
protected function getBackendUserAuthentication(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,196 @@
<?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\Backend\ToolbarItems;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Backend\ColorScheme;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Toolbar\RequestAwareToolbarItemInterface;
use TYPO3\CMS\Backend\Toolbar\ToolbarItemInterface;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* User toolbar item and drop-down.
*
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
class UserToolbarItem implements ToolbarItemInterface, RequestAwareToolbarItemInterface
{
private ServerRequestInterface $request;
public function __construct(
private readonly ModuleProvider $moduleProvider,
private readonly BackendViewFactory $backendViewFactory,
) {}
public function setRequest(ServerRequestInterface $request): void
{
$this->request = $request;
}
/**
* Item is always enabled.
*/
public function checkAccess(): bool
{
return true;
}
/**
* Render username and an icon.
*/
public function getItem(): string
{
$backendUser = $this->getBackendUser();
$view = $this->backendViewFactory->create($this->request);
$view->assignMultiple([
'currentUser' => $backendUser->user,
'switchUserMode' => (int)$backendUser->getOriginalUserIdWhenInSwitchUserMode(),
]);
return $view->render('ToolbarItems/UserToolbarItem');
}
/**
* Render drop-down content.
*/
public function getDropDown(): string
{
$backendUser = $this->getBackendUser();
$mostRecentUsers = [];
if ($backendUser->isAdmin()
&& $backendUser->getOriginalUserIdWhenInSwitchUserMode() === null
&& isset($backendUser->uc['recentSwitchedToUsers'])
&& is_array($backendUser->uc['recentSwitchedToUsers'])
) {
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('be_users');
$result = $queryBuilder
->select('uid', 'username', 'realName')
->from('be_users')
->where(
$queryBuilder->expr()->in('uid', $queryBuilder->createNamedParameter($backendUser->uc['recentSwitchedToUsers'], Connection::PARAM_INT_ARRAY))
)->executeQuery();
// Flip the array to have a "sorted" list of items
$mostRecentUsers = array_flip($backendUser->uc['recentSwitchedToUsers']);
while ($row = $result->fetchAssociative()) {
$mostRecentUsers[$row['uid']] = $row;
}
// Remove any item that is not an array (means, the stored uid is not available anymore)
$mostRecentUsers = array_filter($mostRecentUsers, is_array(...));
$availableUsers = array_keys($mostRecentUsers);
if (!empty(array_diff($backendUser->uc['recentSwitchedToUsers'], $availableUsers))) {
$backendUser->uc['recentSwitchedToUsers'] = $availableUsers;
$backendUser->writeUC();
}
}
$modules = null;
if ($userModule = $this->moduleProvider->getModuleForMenu('user', $backendUser)) {
$modules = $userModule->getSubModules();
}
$helpModules = null;
if ($helpModule = $this->moduleProvider->getModuleForMenu('help', $this->getBackendUser())) {
$helpModules = $helpModule->getSubModules();
}
$view = $this->backendViewFactory->create($this->request);
$view->assignMultiple([
'modules' => $modules,
'helpModules' => $helpModules,
'switchUserMode' => $this->getBackendUser()->getOriginalUserIdWhenInSwitchUserMode() !== null,
'recentUsers' => $mostRecentUsers,
'colorSchemeSwitchEnabled' => $this->getColorSchemeSwitchEnabled(),
'activeColorScheme' => $backendUser->uc['colorScheme'] ?? 'auto',
'colorSchemes' => $this->getColorSchemes(),
]);
return $view->render('ToolbarItems/UserToolbarItemDropDown');
}
/**
* Returns an additional class if user is in "switch user" mode.
*/
public function getAdditionalAttributes(): array
{
$result = [
'class' => 'toolbar-item-user',
];
if ($this->getBackendUser()->getOriginalUserIdWhenInSwitchUserMode()) {
$result['class'] .= ' su-user';
}
return $result;
}
/**
* This item has a drop-down.
*/
public function hasDropDown(): bool
{
return true;
}
/**
* Position relative to others.
*/
public function getIndex(): int
{
return 90;
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getColorSchemeSwitchEnabled(): bool
{
$backendUser = $this->getBackendUser();
$userTS = $backendUser->getTSConfig();
return !isset($userTS['setup.']['fields.']['colorScheme.']['disabled']) || $userTS['setup.']['fields.']['colorScheme.']['disabled'] !== '1';
}
protected function getColorSchemes(): array
{
$schemes = [];
foreach (ColorScheme::cases() as $scheme) {
$schemeItem = [
'label' => $this->getLanguageService()->sL($scheme->getLabel()),
'value' => $scheme->value,
'icon' => $scheme->getIcon(),
];
$schemes[] = $schemeItem;
}
return $schemes;
}
}
+73
View File
@@ -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\Backend\Breadcrumb;
use TYPO3\CMS\Backend\Dto\Breadcrumb\BreadcrumbNode;
use TYPO3\CMS\Core\Domain\RecordInterface;
use TYPO3\CMS\Core\Resource\ResourceInterface;
/**
* Represents a breadcrumb context with the main entity and optional suffix nodes.
*
* A breadcrumb context consists of:
* - A main context (record or resource) that determines the base breadcrumb trail
* - Optional suffix nodes that are appended after the main trail
*
* Suffix nodes are useful for:
* - "New Record" indicators when creating records
* - "Edit Multiple" indicators when editing multiple records
* - Custom action indicators
*
* Example usage:
*
* // Edit existing record
* $context = new BreadcrumbContext($record, []);
*
* // Create new record (shows: Pages > Parent Page > "Create New Content")
* $suffixNode = new BreadcrumbNode(identifier: 'new', label: 'Create New Content');
* $context = new BreadcrumbContext($parentPage, [$suffixNode]);
*
* @internal Subject to change until v15 LTS
*/
final readonly class BreadcrumbContext
{
/**
* @param RecordInterface|ResourceInterface|null $mainContext The main entity (record or resource)
* @param BreadcrumbNode[] $suffixNodes Additional nodes to append after the main breadcrumb trail
*/
public function __construct(
public RecordInterface|ResourceInterface|null $mainContext,
public array $suffixNodes = [],
) {}
/**
* Checks if this context has a valid main entity.
*/
public function hasContext(): bool
{
return $this->mainContext !== null;
}
/**
* Checks if this context has suffix nodes.
*/
public function hasSuffixNodes(): bool
{
return $this->suffixNodes !== [];
}
}
+236
View File
@@ -0,0 +1,236 @@
<?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\Breadcrumb;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Backend\Dto\Breadcrumb\BreadcrumbNode;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Domain\RecordFactory;
use TYPO3\CMS\Core\Domain\RecordInterface;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Resource\ResourceInterface;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
/**
* Factory for creating breadcrumb contexts from controller actions.
*
* This factory centralizes the logic for determining what context to show
* in breadcrumbs based on different controller actions (edit, new, list, etc.).
*
* It handles:
* - Record lookups and validation
* - Creation of "new record" breadcrumb nodes
* - Multi-record edit scenarios
* - Parent record resolution
*
* @internal Subject to change until v15 LTS
*/
final readonly class BreadcrumbFactory
{
public function __construct(
private LoggerInterface $logger,
private RecordFactory $recordFactory,
private IconFactory $iconFactory,
private TcaSchemaFactory $tcaSchemaFactory,
) {}
/**
* Creates breadcrumb context for editing an existing record.
*
* @param string $table The table name
* @param int $uid The record UID
* @return BreadcrumbContext Context containing the record or null on failure
*/
public function forEditAction(string $table, int $uid): BreadcrumbContext
{
$rawRecord = BackendUtility::getRecord($table, $uid);
if ($rawRecord === null) {
$this->logger->warning(
'Failed to load record for breadcrumb',
['table' => $table, 'uid' => $uid]
);
return new BreadcrumbContext(null, []);
}
try {
$record = $this->recordFactory->createResolvedRecordFromDatabaseRow($table, $rawRecord);
return new BreadcrumbContext($record, []);
} catch (\Exception $e) {
// @todo: Catching \Exception here is a code smell, this shouldn't be so generic and can hide away too many issues.
$this->logger->error(
'Failed to create record instance for breadcrumb',
['table' => $table, 'uid' => $uid, 'exception' => $e->getMessage()]
);
return new BreadcrumbContext(null, []);
}
}
/**
* Creates breadcrumb context for editing multiple records.
*
* Shows a generic "Edit Multiple [RecordType]" node instead of individual records.
*
* @param string $table The table name
* @param int $pid The parent page ID
* @return BreadcrumbContext Context with parent page and "edit multiple" suffix node
*/
public function forEditMultipleAction(string $table, int $pid): BreadcrumbContext
{
$parentRecord = $this->getParentPageRecord($pid);
$schema = $this->tcaSchemaFactory->has($table) ? $this->tcaSchemaFactory->get($table) : null;
$recordTypeLabel = $schema?->getTitle($this->getLanguageService()->sL(...))
?? $schema?->getTitle()
?? $table;
$suffixNode = new BreadcrumbNode(
identifier: 'edit-multiple-' . $table,
label: sprintf(
$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.editMultiple'),
$recordTypeLabel
),
icon: $this->iconFactory->getIconForRecord($table, [])->getIdentifier(),
);
return new BreadcrumbContext($parentRecord, [$suffixNode]);
}
/**
* Creates breadcrumb context for creating a new record.
*
* @param string $table The table name
* @param int $pid The parent page ID
* @param array $defaults Default values for the new record (used for icon overlay)
* @return BreadcrumbContext Context with parent page and "create new" suffix node
*/
public function forNewAction(string $table, int $pid, array $defaults = []): BreadcrumbContext
{
$parentRecord = $this->getParentPageRecord($pid);
$schema = $this->tcaSchemaFactory->has($table) ? $this->tcaSchemaFactory->get($table) : null;
$recordTypeLabel = $schema?->getTitle($this->getLanguageService()->sL(...))
?? $schema?->getTitle()
?? $table;
try {
$icon = $this->iconFactory->getIconForRecord($table, $defaults);
$suffixNode = new BreadcrumbNode(
identifier: 'new-' . $table,
label: sprintf(
$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.createNew'),
$recordTypeLabel
),
icon: $icon->getIdentifier(),
iconOverlay: 'overlay-new',
);
} catch (\Exception $e) {
$this->logger->warning(
'Failed to create icon for new record breadcrumb',
['table' => $table, 'exception' => $e->getMessage()]
);
$suffixNode = new BreadcrumbNode(
identifier: 'new-' . $table,
label: sprintf(
$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.createNew'),
$recordTypeLabel
),
);
}
return new BreadcrumbContext($parentRecord, [$suffixNode]);
}
/**
* Creates breadcrumb context from a page record array.
*
* Example:
* `$view->getDocHeaderComponent()->setBreadcrumbContext($this->breadcrumbFactory->forPageArray($pageInfo));`
*
* @param array $pageRecord The page record array (must contain 'uid')
* @return BreadcrumbContext Context with the page record or null on failure
*/
public function forPageArray(array $pageRecord): BreadcrumbContext
{
if (!isset($pageRecord['uid'])) {
$this->logger->warning('Page record array must contain uid for breadcrumb');
return new BreadcrumbContext(null, []);
}
try {
$record = $this->recordFactory->createResolvedRecordFromDatabaseRow('pages', $pageRecord);
return new BreadcrumbContext($record, []);
} catch (\Exception $e) {
$this->logger->error(
'Failed to create page record instance for breadcrumb',
['uid' => $pageRecord['uid'], 'exception' => $e->getMessage()]
);
return new BreadcrumbContext(null, []);
}
}
/**
* Creates breadcrumb context for any resource (file or folder).
*
* @param ResourceInterface $resource The resource (file or folder)
* @return BreadcrumbContext Context with the resource
*/
public function forResource(ResourceInterface $resource): BreadcrumbContext
{
return new BreadcrumbContext($resource, []);
}
/**
* Gets the parent page record for a given PID.
*
* @param int $pid The page ID
* @return RecordInterface|null The page record or null if not found/accessible
*/
private function getParentPageRecord(int $pid): ?RecordInterface
{
if ($pid <= 0) {
return null;
}
$rawRecord = BackendUtility::getRecord('pages', $pid);
if ($rawRecord === null) {
$this->logger->warning(
'Failed to load parent page for breadcrumb',
['pid' => $pid]
);
return null;
}
try {
return $this->recordFactory->createResolvedRecordFromDatabaseRow('pages', $rawRecord);
} catch (\Exception $e) {
// @todo: Catching \Exception here is a code smell, this shouldn't be so generic and can hide away too many issues.
$this->logger->error(
'Failed to create page record instance for breadcrumb',
['pid' => $pid, 'exception' => $e->getMessage()]
);
return null;
}
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,61 @@
<?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\Breadcrumb;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Dto\Breadcrumb\BreadcrumbNode;
/**
* Interface for breadcrumb providers that can generate breadcrumb trails
* for different types of contexts (records, resources, etc.).
*
* Providers are responsible for:
* - Determining if they can handle a given context
* - Generating the appropriate breadcrumb node hierarchy
* - Providing the target module identifier for navigation
*
* @internal Subject to change until v15 LTS
*/
interface BreadcrumbProviderInterface
{
/**
* Determines whether this provider can handle the given context.
*
* @param BreadcrumbContext|null $context The breadcrumb context (can be null for virtual pages)
*/
public function supports(?BreadcrumbContext $context): bool;
/**
* Generates breadcrumb nodes for the given context.
*
* @param BreadcrumbContext|null $context The breadcrumb context (can be null for virtual pages)
* @param ServerRequestInterface|null $request The current request for module detection
* @return BreadcrumbNode[] Array of breadcrumb nodes ordered from root to current
*/
public function generate(?BreadcrumbContext $context, ?ServerRequestInterface $request): array;
/**
* Returns the priority of this provider.
*
* Higher priority providers are checked first. Use this to override
* default providers or to establish a specific order.
*
* @return int Priority (higher = checked first)
*/
public function getPriority(): int;
}
@@ -0,0 +1,145 @@
<?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\Breadcrumb;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Dto\Breadcrumb\BreadcrumbNode;
use TYPO3\CMS\Backend\Module\ModuleInterface;
use TYPO3\CMS\Backend\Module\ModuleResolver;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Resource\StorageRepository;
/**
* Breadcrumb provider for null contexts (virtual pages, empty states).
*
* Provides fallback breadcrumbs when no record or resource context is available,
* such as virtual pages (e.g., id=0) or file storage roots.
*
* @internal This class is not part of TYPO3's public API.
*/
final readonly class NullContextBreadcrumbProvider implements BreadcrumbProviderInterface
{
public function __construct(
private ModuleResolver $moduleResolver,
private StorageRepository $storageRepository,
private UriBuilder $uriBuilder,
) {}
public function supports(?BreadcrumbContext $context): bool
{
// This provider handles null contexts
return $context === null || !$context->hasContext();
}
public function generate(?BreadcrumbContext $context, ?ServerRequestInterface $request): array
{
$breadcrumb = [];
$currentModule = $this->moduleResolver->resolveModule($request);
if ($currentModule !== null) {
// Add parent modules first (for third-level modules)
$breadcrumb = $this->buildModuleHierarchy($currentModule);
}
// Handle file storage tree
if ($currentModule?->getNavigationComponent() === '@typo3/backend/tree/file-storage-tree-container') {
$id = $request?->getQueryParams()['id'] ?? null;
$label = $this->getLanguageService()->sL($currentModule->getTitle());
$icon = 'apps-filetree-folder';
if ($id !== null && $storage = $this->storageRepository->findByCombinedIdentifier($id)) {
$label = $storage->getName();
if (!$storage->isOnline() || !$storage->isBrowsable()) {
$icon = 'apps-filetree-folder-locked';
}
}
$breadcrumb[] = new BreadcrumbNode(
identifier: (string)$id,
label: $label,
icon: $icon,
);
}
// Handle page tree (default for null context or no module)
if ($currentModule === null || $currentModule->getNavigationComponent() === '@typo3/backend/tree/page-tree-element') {
$breadcrumb[] = new BreadcrumbNode(
identifier: '0',
label: (string)$GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'],
icon: 'apps-pagetree-root',
);
}
return $breadcrumb;
}
public function getPriority(): int
{
// Low priority - only handles null contexts as fallback
return 0;
}
/**
* Builds the module hierarchy including parent modules.
*
* For third-level modules, this returns [parent, current].
* For second-level modules, this returns [current].
* For standalone modules, this returns [current].
*
* @return BreadcrumbNode[]
*/
private function buildModuleHierarchy(ModuleInterface $currentModule): array
{
$modules = [];
$moduleChain = [];
// Build chain from current to root
$module = $currentModule;
while ($module !== null) {
$moduleChain[] = $module;
$module = $module->getParentModule();
}
// Reverse to get root-to-current order and skip the top-level parent (main menu item)
$moduleChain = array_reverse($moduleChain);
// Skip the first item if we have more than one (first is the main menu container like "web")
if (count($moduleChain) > 1) {
array_shift($moduleChain);
}
// Build breadcrumb nodes for each module in the chain
foreach ($moduleChain as $module) {
$modules[] = new BreadcrumbNode(
identifier: $module->getIdentifier(),
label: $this->getLanguageService()->sL($module->getTitle()),
icon: $module->getIconIdentifier(),
url: (string)$this->uriBuilder->buildUriFromRoute($module->getIdentifier(), $module->getNavigationComponent() === '@typo3/backend/tree/page-tree-element' ? ['id' => '0'] : []),
forceShowIcon: true,
);
}
return $modules;
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,275 @@
<?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\Breadcrumb;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Backend\Dto\Breadcrumb\BreadcrumbNode;
use TYPO3\CMS\Backend\Module\ModuleInterface;
use TYPO3\CMS\Backend\Module\ModuleResolver;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Domain\RecordInterface;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
/**
* Breadcrumb provider for TYPO3 records (pages, content elements, etc.).
*
* Generates breadcrumb trails based on page rootlines and record hierarchies.
*
* @internal This class is not part of TYPO3's public API.
*/
final readonly class RecordBreadcrumbProvider implements BreadcrumbProviderInterface
{
public function __construct(
private IconFactory $iconFactory,
private ModuleResolver $moduleResolver,
private UriBuilder $uriBuilder,
private LoggerInterface $logger,
) {}
public function supports(?BreadcrumbContext $context): bool
{
return $context?->mainContext instanceof RecordInterface;
}
public function generate(?BreadcrumbContext $context, ?ServerRequestInterface $request): array
{
if ($context === null || !$context->mainContext instanceof RecordInterface) {
return [];
}
$record = $context->mainContext;
$breadcrumb = [];
$currentModule = $this->moduleResolver->resolveModule($request);
$showRootline = $this->shouldShowRootline($currentModule);
$targetModule = $currentModule !== null
? $this->extractRouteIdentifier($request, $currentModule)
: $this->getTargetModule();
// Add module hierarchy (for third-level modules, this includes parent modules)
if ($currentModule !== null) {
$breadcrumb = array_merge($breadcrumb, $this->buildModuleHierarchy($currentModule, $request, $showRootline));
} else {
$breadcrumb[] = new BreadcrumbNode(
identifier: '0',
label: (string)$GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'],
icon: 'apps-pagetree-root',
url: (string)$this->uriBuilder->buildUriFromRoute($targetModule, $showRootline ? ['id' => '0'] : []),
);
}
// Add page rootline if applicable
if ($showRootline) {
$breadcrumb = array_merge($breadcrumb, $this->buildRootline($record, $targetModule));
}
// Add the current record
$breadcrumb[] = $this->buildRecordNode($record, $targetModule);
return $breadcrumb;
}
public function getPriority(): int
{
return 10;
}
/**
* Returns the target module identifier for navigation.
*/
private function getTargetModule(): string
{
// Default to web_layout for page-based navigation
return 'web_layout';
}
/**
* Builds the page rootline for a record.
*
* @return BreadcrumbNode[]
*/
private function buildRootline(RecordInterface $record, string $targetModule): array
{
$breadcrumb = [];
$pid = $record->getPid();
try {
$rootline = BackendUtility::BEgetRootLine($pid);
if ($rootline === []) {
return [];
}
// Remove the site root (already added as first node)
array_pop($rootline);
ksort($rootline);
foreach ($rootline as $item) {
if (!is_array($item) || !isset($item['uid'])) {
continue;
}
try {
$icon = $this->iconFactory->getIconForRecord('pages', $item, IconSize::SMALL);
$breadcrumb[] = new BreadcrumbNode(
identifier: (string)$item['uid'],
label: BackendUtility::cropToTitleLength($item['title'] ?? ''),
icon: $icon->getIdentifier(),
iconOverlay: $icon->getOverlayIcon()?->getIdentifier(),
url: (string)$this->uriBuilder->buildUriFromRoute($targetModule, ['id' => $item['uid']]),
);
} catch (\Exception $e) {
$this->logger->warning(
'Failed to create breadcrumb node for page',
['uid' => $item['uid'], 'exception' => $e->getMessage()]
);
}
}
} catch (\Exception $e) {
$this->logger->warning(
'Failed to build rootline for record',
['table' => $record->getMainType(), 'uid' => $record->getUid(), 'exception' => $e->getMessage()]
);
}
return $breadcrumb;
}
/**
* Builds a breadcrumb node for the current record.
*/
private function buildRecordNode(RecordInterface $record, string $targetModule): BreadcrumbNode
{
try {
$icon = $this->iconFactory->getIconForRecord(
$record->getMainType(),
$record->getRawRecord()?->toArray(),
IconSize::SMALL
);
$recordTitle = BackendUtility::getRecordTitle($record->getMainType(), $record->getRawRecord()?->toArray());
return new BreadcrumbNode(
identifier: (string)$record->getUid(),
label: BackendUtility::cropToTitleLength($recordTitle),
icon: $icon->getIdentifier(),
iconOverlay: $icon->getOverlayIcon()?->getIdentifier(),
url: $record->getMainType() === 'pages' ? (string)$this->uriBuilder->buildUriFromRoute($targetModule, ['id' => (string)$record->getUid()]) : null,
);
} catch (\Exception $e) {
$this->logger->error(
'Failed to create breadcrumb node for record',
['table' => $record->getMainType(), 'uid' => $record->getUid(), 'exception' => $e->getMessage()]
);
// Return a minimal fallback node
return new BreadcrumbNode(
identifier: (string)$record->getUid(),
label: $record->getMainType() . ' [' . $record->getUid() . ']',
);
}
}
/**
* Builds the module hierarchy including parent modules.
*
* For third-level modules, this returns [parent, current].
* For second-level modules, this returns [current].
* For standalone modules, this returns [current].
*
* @return BreadcrumbNode[]
*/
private function buildModuleHierarchy(ModuleInterface $currentModule, ?ServerRequestInterface $request, bool $showRootline): array
{
$modules = [];
$moduleChain = [];
// Build chain from current to root
$module = $currentModule;
while ($module !== null) {
$moduleChain[] = $module;
$module = $module->getParentModule();
}
// Reverse to get root-to-current order and skip the top-level parent (main menu item)
$moduleChain = array_reverse($moduleChain);
// Skip the first item if we have more than one (first is the main menu container like "web")
if (count($moduleChain) > 1) {
array_shift($moduleChain);
}
// Build breadcrumb nodes for each module in the chain
foreach ($moduleChain as $index => $module) {
$isLastModule = $index === count($moduleChain) - 1;
// For the last module (current), use the full route identifier to preserve route/action
// For parent modules, use base module identifier
$routeIdentifier = $isLastModule ? $this->extractRouteIdentifier($request, $module) : $module->getIdentifier();
$modules[] = new BreadcrumbNode(
identifier: $module->getIdentifier(),
label: $this->getLanguageService()->sL($module->getTitle()),
icon: $module->getIconIdentifier(),
url: (string)$this->uriBuilder->buildUriFromRoute($routeIdentifier, $showRootline ? ['id' => '0'] : []),
forceShowIcon: true,
);
}
return $modules;
}
/**
* Determines if the rootline should be shown based on the current module.
*
* Modules using the page tree navigation component typically support page-based navigation.
*/
private function shouldShowRootline(?ModuleInterface $currentModule): bool
{
// @todo This is quite implicit, but using the page-tree-element as navigation component
// signals that the current module can handle ?id= as a page parameter.
return $currentModule === null || $currentModule->getNavigationComponent() === '@typo3/backend/tree/page-tree-element';
}
/**
* Extracts the route identifier from the current request.
*
* This returns the full route identifier (e.g., 'manage_search_index.Administration_externalDocuments')
* to preserve sub-routes and actions in breadcrumb navigation.
*
* @return string The route identifier or module identifier as fallback
*/
private function extractRouteIdentifier(?ServerRequestInterface $request, ModuleInterface $module): string
{
// Try to get the full route identifier from routing attribute
if ($request !== null
&& ($routeResult = $request->getAttribute('routing')) !== null
&& ($route = $routeResult->getRoute()) !== null
&& !empty(($routeIdentifier = $route->getOption('_identifier')))
) {
return $routeIdentifier;
}
// Fallback to module identifier
return $module->getIdentifier();
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,201 @@
<?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\Breadcrumb;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use TYPO3\CMS\Backend\Dto\Breadcrumb\BreadcrumbNode;
use TYPO3\CMS\Backend\Module\ModuleResolver;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException;
use TYPO3\CMS\Core\Resource\FileInterface;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\FolderInterface;
use TYPO3\CMS\Core\Resource\ResourceInterface;
/**
* Breadcrumb provider for FAL resources (files and folders).
*
* Generates breadcrumb trails based on folder hierarchies and storage structures.
*
* @internal This class is not part of TYPO3's public API.
*/
final readonly class ResourceBreadcrumbProvider implements BreadcrumbProviderInterface
{
public function __construct(
private IconFactory $iconFactory,
private ModuleResolver $moduleResolver,
private UriBuilder $uriBuilder,
private LoggerInterface $logger,
) {}
public function supports(?BreadcrumbContext $context): bool
{
return $context?->mainContext instanceof ResourceInterface;
}
public function generate(?BreadcrumbContext $context, ?ServerRequestInterface $request): array
{
if ($context === null || !$context->mainContext instanceof ResourceInterface) {
return [];
}
$resource = $context->mainContext;
$breadcrumb = [];
$currentModule = $this->moduleResolver->resolveModule($request);
// Add module node
if ($currentModule !== null) {
$languageService = $this->getLanguageService();
$breadcrumb[] = new BreadcrumbNode(
identifier: $currentModule->getIdentifier(),
label: $languageService->sL($currentModule->getTitle()),
icon: $currentModule->getIconIdentifier(),
iconOverlay: null,
url: (string)$this->uriBuilder->buildUriFromRoute($currentModule->getIdentifier(), ['id' => '']),
forceShowIcon: true,
);
}
// Build resource hierarchy
$resourceHierarchy = $this->buildResourceHierarchy($resource);
// Add resource nodes
foreach ($resourceHierarchy as $item) {
try {
$icon = $this->iconFactory->getIconForResource($item, IconSize::SMALL);
$label = $item->getName();
$combinedIdentifier = $this->getCombinedIdentifier($item);
// Use storage name for root folder
if ($item->getIdentifier() === $item->getStorage()->getRootLevelFolder()->getIdentifier()) {
$label = $item->getStorage()->getName();
}
$breadcrumb[] = new BreadcrumbNode(
identifier: $combinedIdentifier,
label: $label,
icon: $icon->getIdentifier(),
iconOverlay: $icon->getOverlayIcon()?->getIdentifier(),
url: (string)$this->uriBuilder->buildUriFromRoute($this->getTargetModule(), ['id' => $combinedIdentifier]),
);
} catch (\Exception $e) {
$this->logger->warning(
'Failed to create breadcrumb node for resource',
['identifier' => $item->getIdentifier(), 'exception' => $e->getMessage()]
);
}
}
return $breadcrumb;
}
public function getPriority(): int
{
return 10;
}
/**
* Returns the target module identifier for navigation.
*/
private function getTargetModule(): string
{
return 'media_management';
}
/**
* Builds the resource hierarchy from root to current resource.
*
* @return ResourceInterface[]
*/
private function buildResourceHierarchy(ResourceInterface $resource): array
{
$hierarchy = [];
$folder = null;
// Start with the resource itself
if ($resource instanceof FileInterface) {
$hierarchy[] = $resource;
try {
$folder = $resource->getParentFolder();
} catch (\Exception $e) {
$this->logger->warning(
'Failed to get parent folder for file',
['identifier' => $resource->getIdentifier(), 'exception' => $e->getMessage()]
);
return $hierarchy;
}
} elseif ($resource instanceof FolderInterface) {
$folder = $resource;
}
// Traverse up the folder hierarchy
if ($folder instanceof Folder) {
$currentFolder = $folder;
$hierarchy[] = $folder;
// Walk up to the root folder
$maxDepth = 100; // Safety limit to prevent infinite loops
$depth = 0;
while ($depth < $maxDepth) {
$depth++;
try {
$parent = $currentFolder->getParentFolder();
} catch (InsufficientFolderAccessPermissionsException $e) {
// User doesn't have access to parent folder, stop here
$this->logger->info(
'Stopped breadcrumb traversal due to insufficient folder access',
['folder' => $currentFolder->getCombinedIdentifier()]
);
break;
}
// Check if we've reached the root (parent points to itself)
if ($parent->getCombinedIdentifier() === $currentFolder->getCombinedIdentifier()) {
break;
}
// Add parent to hierarchy and continue upwards
$hierarchy[] = $parent;
$currentFolder = $parent;
}
}
// Reverse to get root-to-current order
return array_reverse($hierarchy);
}
/**
* Gets the combined identifier for a resource.
* Constructs it from storage UID and resource identifier.
*/
private function getCombinedIdentifier(ResourceInterface $resource): string
{
return $resource->getStorage()->getUid() . ':' . $resource->getIdentifier();
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+830
View File
@@ -0,0 +1,830 @@
<?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\Clipboard;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Clipboard\Type\CountMode;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Authentication\JsConfirmation;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Resource\Exception\InsufficientFolderAccessPermissionsException;
use TYPO3\CMS\Core\Resource\Exception\ResourceDoesNotExistException;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\ProcessedFile;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* TYPO3 clipboard for records and files
*
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
class Clipboard
{
/**
* Clipboard data kept here
*
* Keys:
* 'normal'
* 'tab_[x]' where x is >=1 and denotes the pad-number
* 'mode' : 'copy' means copy-mode, default = moving ('cut')
* 'el' : Array of elements:
* DB: keys = '[tablename]|[uid]' eg. 'tt_content:123'
* DB: values = 1 (basically insignificant)
* FILE: keys = '_FILE|[md5 of path]' eg. '_FILE|9ebc7e5c74'
* FILE: values = The full filepath, eg. '/www/htdocs/typo3/32/dummy/fileadmin/sem1_3_examples/alternative_index.php'
* or 'C:/www/htdocs/typo3/32/dummy/fileadmin/sem1_3_examples/alternative_index.php'
*
* 'current' pointer to current tab (among the above...)
*
* The virtual tablename '_FILE' will always indicate files/folders. When checking for elements from eg. 'all tables'
* (by using an empty string) '_FILE' entries are excluded (so in effect only DB elements are counted)
*/
public array $clipData = [];
public bool $changed = false;
public string $current = '';
public bool $lockToNormal = false;
public int $numberOfPads = 3;
protected ?ServerRequestInterface $request = null;
public function __construct(
protected readonly IconFactory $iconFactory,
protected readonly UriBuilder $uriBuilder,
protected readonly ResourceFactory $resourceFactory,
protected readonly TcaSchemaFactory $tcaSchemaFactory,
) {}
/*****************************************
*
* Initialize
*
****************************************/
/**
* Initialize the clipboard from the be_user session
*/
public function initializeClipboard(?ServerRequestInterface $request = null): void
{
// Initialize the request
// @todo: Clipboard does two things: It is a repository to find out which records
// are in the clipboard, and it is a class to help with rendering the
// clipboard. $request is optional and only used in rendering.
// It would be better to split these two aspects into single classes.
$this->request = $request ?? $GLOBALS['TYPO3_REQUEST'] ?? null;
$userTsConfig = $this->getBackendUser()->getTSConfig();
// Get data
$clipData = $this->getBackendUser()->getModuleData('clipboard', !empty($userTsConfig['options.']['saveClipboard']) ? '' : 'ses') ?: [];
$clipData += ['normal' => []];
$this->numberOfPads = MathUtility::forceIntegerInRange((int)($userTsConfig['options.']['clipboardNumberPads'] ?? 3), 0, 20);
// Resets/reinstates the clipboard pads
$this->clipData['normal'] = is_array($clipData['normal']) ? $clipData['normal'] : [];
for ($a = 1; $a <= $this->numberOfPads; $a++) {
$index = 'tab_' . $a;
$this->clipData[$index] = is_iterable($clipData[$index] ?? null) ? $clipData[$index] : [];
}
// Setting the current pad pointer ($this->current))
$current = (string)($clipData['current'] ?? '');
$this->current = isset($this->clipData[$current]) ? $current : 'normal';
$this->clipData['current'] = $this->current;
}
/**
* Call this method after initialization if you want to lock the clipboard to operate on the normal pad only.
* Trying to switch pad through ->setCmd will not work.
* This is used by the clickmenu since it only allows operation on single elements at a time (that is the "normal" pad)
*/
public function lockToNormal(): void
{
$this->lockToNormal = true;
$this->current = 'normal';
}
/**
* The array $cmd may hold various keys which notes some action to take.
* Normally perform only one action at a time.
* In scripts like db_list.php / filelist/mod1/index.php the GET-var CB is used to control the clipboard.
*
* Selecting / Deselecting elements
* Array $cmd['el'] has keys = element-ident, value = element value (see description of clipData array in header)
* Selecting elements for 'copy' should be done by simultaneously setting setCopyMode.
*
* @param array $cmd Array of actions, see function description
*/
public function setCmd(array $cmd): void
{
$cmd['el'] ??= [];
$cmd['el'] = is_iterable($cmd['el']) ? $cmd['el'] : [];
foreach ($cmd['el'] as $key => $value) {
if ($this->current === 'normal') {
unset($this->clipData['normal']);
}
if ($value) {
$this->clipData[$this->current]['el'][$key] = $value;
} else {
$this->removeElement((string)$key);
}
$this->changed = true;
}
// Change clipboard pad (if not locked to normal)
if ($cmd['setP'] ?? false) {
$this->setCurrentPad((string)$cmd['setP']);
}
// Remove element (value = item ident: DB; '[tablename]|[uid]' FILE: '_FILE|[md5 hash of path]'
if ($cmd['remove'] ?? false) {
$this->removeElement((string)$cmd['remove']);
$this->changed = true;
}
// Remove all on current pad (value = pad-ident)
if ($cmd['removeAll'] ?? false) {
$this->clipData[$cmd['removeAll']] = [];
$this->changed = true;
}
// Set copy mode of the tab
if (isset($cmd['setCopyMode'])) {
$this->clipData[$this->current]['mode'] = $cmd['setCopyMode'] ? 'copy' : '';
$this->changed = true;
}
}
/**
* Setting the current pad on clipboard
*
* @param string $padIdentifier Key in the array $this->clipData
*/
public function setCurrentPad(string $padIdentifier): void
{
// Change clipboard pad (if not locked to normal)
if (!$this->lockToNormal && $this->current !== $padIdentifier) {
if (isset($this->clipData[$padIdentifier])) {
$this->clipData['current'] = ($this->current = $padIdentifier);
}
if ($this->current !== 'normal' || !$this->isElements()) {
$this->clipData[$this->current]['mode'] = '';
}
// Setting mode to default (move) if no items on it or if not 'normal'
$this->changed = true;
}
}
/**
* Call this after initialization and setCmd in order to save the clipboard to the user session.
* The function will check if the internal flag ->changed has been set and if so, save the clipboard. Else not.
*/
public function endClipboard(): void
{
if ($this->changed) {
$this->saveClipboard();
}
$this->changed = false;
}
/**
* Cleans up an incoming element array $CBarr (Array selecting/deselecting elements)
*
* @param array $CBarr Element array from outside ("key" => "selected/deselected")
* @param string $table The 'table which is allowed'. Must be set.
* @param bool $removeDeselected Can be set in order to remove entries which are marked for deselection.
* @return array Processed input $CBarr
*/
public function cleanUpCBC(array $CBarr, string $table, bool $removeDeselected = false): array
{
foreach ($CBarr as $reference => $value) {
[$referenceTable] = explode('|', $reference, 2);
if ($referenceTable !== $table || ($removeDeselected && !$value)) {
unset($CBarr[$reference]);
}
}
return $CBarr;
}
public function getClipboardData(string $table = ''): array
{
$lang = $this->getLanguageService();
$clipboardData = [
'current' => $this->current,
'copyMode' => $this->currentMode(),
'elementCount' => count($this->elFromTable($table)),
];
// Initialize tabs by adding the "normal" tab
$tabs = [
[
'identifier' => 'normal',
'info' => $this->getTabInfo('normal', $table),
'title' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.normal'),
'description' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.normal-description'),
'items' => $this->current === 'normal' ? $this->getTabItems('normal', $table) : [],
],
];
// Add numeric tabs
for ($a = 1; $a <= $this->numberOfPads; $a++) {
$tabs[] = [
'identifier' => 'tab_' . $a,
'info' => $this->getTabInfo('tab_' . $a, $table),
'title' => sprintf($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.cliptabs-name'), (string)$a),
'description' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.cliptabs-description'),
'items' => $this->current === 'tab_' . $a ? $this->getTabItems('tab_' . $a, $table) : [],
];
}
// Add tabs to clipboard Data
$clipboardData['tabs'] = $tabs;
return $clipboardData;
}
/**
* Get the items for the given pad identifier
*
* @param string $padIdentifier Pad reference
* @return array The tab items
*/
protected function getTabItems(string $padIdentifier, string $currentTable): array
{
if (!is_array($this->clipData[$padIdentifier]['el'] ?? false)) {
return [];
}
$records = [];
foreach ($this->clipData[$padIdentifier]['el'] as $reference => $value) {
if (!$value) {
// Skip element if empty value
continue;
}
[$table, $uid] = explode('|', $reference);
// Rendering files/directories on the clipboard
if ($table === '_FILE') {
$fileObject = $this->resourceFactory->retrieveFileOrFolderObject($value);
if ($fileObject) {
$thumb = '';
$folder = $fileObject instanceof Folder;
$size = $folder ? '' : '(' . GeneralUtility::formatSize((int)$fileObject->getSize()) . 'bytes)';
/** @var File $fileObject */
if (!$folder && ($fileObject->isImage() || $fileObject->isMediaFile())) {
$processedFile = $fileObject->process(
ProcessedFile::CONTEXT_IMAGECROPSCALEMASK,
[
'maxWidth' => 64,
'maxHeight' => 64,
]
);
$thumb = '<img src="' . htmlspecialchars($processedFile->getPublicUrl() ?? '') . '" '
. 'width="' . htmlspecialchars((string)$processedFile->getProperty('width')) . '" '
. 'height="' . htmlspecialchars((string)$processedFile->getProperty('height')) . '" '
. 'title="' . htmlspecialchars($processedFile->getName()) . '" alt="" loading="lazy" />';
}
$linkItemText = BackendUtility::cropToTitleLength($fileObject->getName());
$combinedIdentifier = $fileObject->getParentFolder()->getCombinedIdentifier();
$filesRequested = $currentTable === '_FILE';
$records[] = [
'identifier' => '_FILE|' . md5($value),
'icon' => $this->iconFactory
->getIconForResource($fileObject, IconSize::SMALL)
->setTitle($fileObject->getName() . ' ' . $size)
->render(),
'title' => $this->linkItemText(htmlspecialchars($linkItemText), $combinedIdentifier, $filesRequested),
'thumb' => $thumb,
'infoDataDispatch' => [
'action' => 'TYPO3.InfoWindow.showItem',
'args' => GeneralUtility::jsonEncodeForHtmlAttribute([$table, $value], false),
],
];
} else {
// If the file did not exist (or is illegal) then it is removed from the clipboard immediately:
unset($this->clipData[$padIdentifier]['el'][$reference]);
$this->changed = true;
}
} else {
// Rendering records:
$record = BackendUtility::getRecordWSOL($table, (int)$uid);
if (is_array($record)) {
$isRequestedTable = $currentTable !== '_FILE';
$records[] = [
'identifier' => $table . '|' . $uid,
'icon' => $this->iconFactory->getIconForRecord($table, $record, IconSize::SMALL)->render(),
'title' => $this->linkItemText(htmlspecialchars(BackendUtility::cropToTitleLength(BackendUtility::getRecordTitle(
$table,
$record
))), $record, $isRequestedTable),
'infoDataDispatch' => [
'action' => 'TYPO3.InfoWindow.showItem',
'args' => GeneralUtility::jsonEncodeForHtmlAttribute([$table, (int)$uid], false),
],
];
$localizationData = $this->getLocalizations($table, $record, $isRequestedTable);
if (!empty($localizationData)) {
$records = array_merge($records, $localizationData);
}
} else {
unset($this->clipData[$padIdentifier]['el'][$reference]);
$this->changed = true;
}
}
}
$this->endClipboard();
return $records;
}
/**
* Returns true if the clipboard contains elements
*/
public function hasElements(): bool
{
foreach ($this->clipData as $data) {
if (isset($data['el']) && is_array($data['el']) && !empty($data['el'])) {
return true;
}
}
return false;
}
/**
* Gets all localizations of the current record.
*
* @param string $table The table
* @param array $parentRecord The parent record
* @param bool $isRequestedTable Whether the element is from the requested table
* @return array HTML table rows
*/
protected function getLocalizations(string $table, array $parentRecord, bool $isRequestedTable): array
{
if (!$this->tcaSchemaFactory->has($table)) {
return [];
}
$schema = $this->tcaSchemaFactory->get($table);
if (!$schema->isLanguageAware()) {
return [];
}
$languageCapability = $schema->getCapability(TcaSchemaCapability::Language);
$records = [];
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUser()->workspace));
$queryBuilder
->select('*')
->from($table)
->where(
$queryBuilder->expr()->eq(
$languageCapability->getTranslationOriginPointerField()->getName(),
$queryBuilder->createNamedParameter((int)$parentRecord['uid'], Connection::PARAM_INT)
),
$queryBuilder->expr()->neq(
$languageCapability->getLanguageField()->getName(),
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
),
$queryBuilder->expr()->gt(
'pid',
$queryBuilder->createNamedParameter(-1, Connection::PARAM_INT)
)
)
->orderBy($languageCapability->getLanguageField()->getName());
foreach ($queryBuilder->executeQuery()->fetchAllAssociative() as $record) {
$title = htmlspecialchars(BackendUtility::cropToTitleLength(BackendUtility::getRecordTitle($table, $record)));
if (!$isRequestedTable) {
// In case the current table is not the requested table, e.g. "_FILE", wrap title in "muted" style
$title = '<span class="text-variant">' . $title . '</span>';
}
$records[] = [
'icon' => $this->iconFactory->getIconForRecord($table, $record, IconSize::SMALL)->render(),
'title' => $title,
'infoDataDispatch' => [
'action' => 'TYPO3.InfoWindow.showItem',
'args' => GeneralUtility::jsonEncodeForHtmlAttribute([$table, (int)$record['uid']], false),
],
];
}
return $records;
}
/**
* Additional information for the tab. This is either
* the current copyMode (for "normal") or the elements
* count, for numeric tabs. Latter will not be shown,
* in case no elements exist for the tab.
*
* @param string $padIdentifier Identifier for the clipboard pad
* @param string $table The table name to count for elements
*/
protected function getTabInfo(string $padIdentifier, string $table = ''): string
{
$el = count($this->elFromTable($table, $padIdentifier));
if (!$el) {
return '';
}
$modeLabel = ($this->clipData['normal']['mode'] ?? '') === 'copy' ? $this->clipboardLabel('cm.copy') : $this->clipboardLabel('cm.cut');
return ' (' . ($padIdentifier === 'normal' ? $modeLabel : htmlspecialchars((string)$el)) . ')';
}
/**
* Wraps the title of the element in a link to the page/folder where they originate from.
* Will be wrapped into "muted" style in case the element is not from the currently requested table.
*
* @param string $itemText Title of element - must be htmlspecialchar'ed on beforehand.
* @param array|string $reference If array, a record is expected. If string, its the folders' combined identifier
* @param bool $isRequestedTable Whether the element is from the requested table
*/
protected function linkItemText(string $itemText, $reference, bool $isRequestedTable): string
{
if (is_array($reference)) {
if ($isRequestedTable) {
// Wrap in link to corresponding page in recordlist in case current requested table matches
$itemText = '<a href="' . htmlspecialchars((string)$this->uriBuilder->buildUriFromRoute('records', ['id' => $reference['pid']])) . '">' . $itemText . '</a>';
} else {
$itemText = '<span class="text-variant">' . $itemText . '</span>';
}
} elseif (is_string($reference)) {
if ($isRequestedTable && ExtensionManagementUtility::isLoaded('filelist')) {
// Wrap in link to the files folder in case current requested table matches and filelist is loaded
$itemText = '<a href="' . htmlspecialchars((string)$this->uriBuilder->buildUriFromRoute('media_management', ['id' => $reference])) . '">' . $itemText . '</a>';
} else {
$itemText = '<span class="text-variant">' . $itemText . '</span>';
}
}
return $itemText;
}
/**
* Returns the select-url for database elements
*
* @param string $table Table name
* @param int $uid Uid of record
* @param bool $copy If set, copymode will be enabled
* @param bool $deselect If set, the link will deselect, otherwise select.
* @return string URL linking to the current script but with the CB array set to select the element with table/uid
*/
public function selUrlDB(string $table, int $uid, bool $copy = false, bool $deselect = false): string
{
return $this->buildUrl(['CB' => [
'el' => [
$table . '|' . $uid => $deselect ? 0 : 1,
],
'setCopyMode' => (int)$copy,
]]);
}
/**
* Returns the select-url for files
*
* @param string $path Filepath
* @param bool $copy If set, copymode will be enabled
* @param bool $deselect If set, the link will deselect, otherwise select.
* @return string URL linking to the current script but with the CB array set to select the path
*/
public function selUrlFile(string $path, bool $copy = false, bool $deselect = false): string
{
return $this->buildUrl(['CB' => [
'el' => [
'_FILE|' . md5($path) => $deselect ? '' : $path,
],
'setCopyMode' => (int)$copy,
]]);
}
/**
* pasteUrl of the element (database and file)
* For the meaning of $table and $uid, please read from ->makePasteCmdArray!!!
* The URL will point to tce_file or tce_db depending in $table
*
* @param string $table Tablename (_FILE for files)
* @param string|int $identifier "destination": can be positive or negative indicating how the paste is done
* (paste into / paste after). For files, this is the combined identifier.
* @param bool $setRedirect If set, then the redirect URL will point back to the current script, but with CB reset.
* @param array|null $update Additional key/value pairs which should get set in the moved/copied record (via DataHandler)
*/
public function pasteUrl(string $table, $identifier, bool $setRedirect = true, ?array $update = null): string
{
$urlParameters = [
'CB' => [
'paste' => $table . '|' . $identifier,
'pad' => $this->current,
],
];
if ($setRedirect) {
$urlParameters['redirect'] = $this->buildUrl(['CB' => []]);
}
if (is_array($update)) {
$urlParameters['CB']['update'] = $update;
}
return (string)$this->uriBuilder->buildUriFromRoute($table === '_FILE' ? 'tce_file' : 'tce_db', $urlParameters);
}
/**
* Returns confirm JavaScript message
*
* @param string $table Table name
* @param array|string $reference For records its an array, for files its a string (path)
* @param string $type Type-code
* @return string the text for a confirm message
*/
public function confirmMsgText(
string $table,
$reference,
string $type,
CountMode $countMode = CountMode::CURRENT,
): string {
if (!$this->getBackendUser()->jsConfirmation(JsConfirmation::COPY_MOVE_PASTE)) {
return '';
}
$selectedElements = match ($countMode) {
CountMode::CURRENT => $this->elFromTable($table),
CountMode::ALL => $this->elFromTable(),
};
$labelKey = 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:mess.'
. ($this->currentMode() === 'copy' ? 'copy' : 'move')
. ($this->current === 'normal' ? '' : 'cb') . '_' . $type;
$confirmationMessage = $this->getLanguageService()->sL($labelKey);
if ($table === '_FILE' && is_string($reference)) {
$recordTitle = PathUtility::basename($reference);
if ($this->current === 'normal') {
$selectedItem = reset($selectedElements);
$selectedRecordTitle = PathUtility::basename($selectedItem);
} else {
$selectedRecordTitle = (string)count($selectedElements);
}
} else {
$recordTitle = $table === 'pages' && !is_array($reference)
? $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename']
: BackendUtility::getRecordTitle($table, $reference);
if ($this->current === 'normal') {
$selectedItem = $this->getSelectedRecord();
$selectedRecordTitle = $selectedItem['_RECORD_TITLE'];
} else {
$selectedRecordTitle = (string)count($selectedElements);
}
}
return sprintf(
$confirmationMessage,
GeneralUtility::fixed_lgd_cs($selectedRecordTitle, 30),
GeneralUtility::fixed_lgd_cs($recordTitle, 30)
);
}
/**
* Clipboard label - getting from "EXT:core/Resources/Private/Language/locallang_core.xlf:"
*
* @param string $key Label Key
* @return string htmspecialchared' label
*/
protected function clipboardLabel(string $key): string
{
return htmlspecialchars($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:' . $key));
}
/*****************************************
*
* Helper functions
*
****************************************/
/**
* Removes element on clipboard
*
* @param string $elementKey Key of element in ->clipData array
*/
public function removeElement(string $elementKey): void
{
unset($this->clipData[$this->current]['el'][$elementKey]);
$this->changed = true;
}
/**
* Saves the clipboard, no questions asked.
* Use ->endClipboard normally (as it checks if changes has been done so saving is necessary)
*/
protected function saveClipboard(): void
{
$this->getBackendUser()->pushModuleData('clipboard', $this->clipData);
}
/**
* Returns the current mode, 'copy' or 'cut'
*
* @return string "copy" or "cut
*/
public function currentMode(): string
{
return ($this->clipData[$this->current]['mode'] ?? '') === 'copy' ? 'copy' : 'cut';
}
/**
* This traverses the elements on the current clipboard pane
* and unsets elements which does not exist anymore or are disabled.
*/
public function cleanCurrent(): void
{
if (!is_array($this->clipData[$this->current]['el'] ?? false)) {
return;
}
foreach ($this->clipData[$this->current]['el'] as $reference => $value) {
[$table, $uid] = explode('|', $reference);
$unset = false;
if (!$value) {
$unset = true;
} elseif ($table === '_FILE') {
try {
$fileOrFolder = $this->resourceFactory->retrieveFileOrFolderObject($value);
if (($fileOrFolder instanceof File || $fileOrFolder instanceof Folder)
&& !$fileOrFolder->checkActionPermission('read')
) {
$unset = true;
}
} catch (InsufficientFolderAccessPermissionsException|ResourceDoesNotExistException) {
// If either the file has been deleted in the meantime or the user lacks permissions
// for the folder, we just remove the clipboard entry silently
$unset = true;
}
} elseif (!$this->isRecordAccessAllowed($table, (int)$uid)) {
$unset = true;
}
if ($unset) {
$this->removeElement($reference);
}
}
}
protected function isRecordAccessAllowed(string $table, int $uid): bool
{
$row = BackendUtility::getRecord($table, (int)$uid, ['uid', 'pid']);
if (!is_array($row)) {
return false;
}
if (!$this->getBackendUser()->check('tables_select', $table)) {
return false;
}
$schema = $this->tcaSchemaFactory->get($table);
$rootLevelCapability = $schema->getCapability(TcaSchemaCapability::RestrictionRootLevel);
$pid = (int)($table === 'pages' ? $row['uid'] : $row['pid']);
if ($pid === 0) {
return $this->getBackendUser()->isAdmin() || $rootLevelCapability->canAccessRecordsOnRootLevel();
}
$page = BackendUtility::getRecord('pages', $pid);
if (!is_array($page)) {
return false;
}
if (!$this->getBackendUser()->doesUserHaveAccess($page, Permission::PAGE_SHOW)) {
return false;
}
return true;
}
/**
* Counts the number of elements from the table $matchTable. If $matchTable is blank, all tables (except '_FILE' of course) is counted.
*
* @param string $matchTable Table to match/count for.
* @param string $padIdentifier Can optionally be used to set another pad than the current.
* @return array Array with keys from the CB.
*/
public function elFromTable(string $matchTable = '', string $padIdentifier = ''): array
{
$padIdentifier = $padIdentifier ?: $this->current;
if (!is_array($this->clipData[$padIdentifier]['el'] ?? false)) {
return [];
}
$elements = [];
foreach ($this->clipData[$padIdentifier]['el'] as $reference => $value) {
if (!$value) {
continue;
}
[$table, $uid] = explode('|', $reference);
if ($table !== '_FILE') {
if ((!$matchTable || $table === $matchTable) && $this->tcaSchemaFactory->has($table)) {
$elements[$reference] = $padIdentifier === 'normal' ? $value : $uid;
}
} elseif ($table === $matchTable) {
$elements[$reference] = $value;
}
}
return $elements;
}
/**
* Verifies if the item $table/$uid is on the current pad.
* If the pad is "normal" and the element exists, the mode value is returned.
* Thus you'll know if the item was copied or cut.
*
* @param string $table Table name, (_FILE for files...)
* @param string|int $identifier Either the records' uid or a filepath
* @return string If selected the current mode is returned, otherwise an empty string
*/
public function isSelected(string $table, $identifier): string
{
$key = $table . '|' . $identifier;
$mode = $this->current === 'normal' ? $this->currentMode() : 'any';
return !empty($this->clipData[$this->current]['el'][$key]) ? $mode : '';
}
/**
* Returns the first element on the current clipboard
* Makes sense only for DB records - not files!
*
* @return array Element record with extra field _RECORD_TITLE set to the title of the record
*/
public function getSelectedRecord(): array
{
$elements = $this->elFromTable();
reset($elements);
[$table, $uid] = explode('|', (string)key($elements));
if (!$this->isSelected($table, (int)$uid)) {
return [];
}
$selectedRecord = BackendUtility::getRecordWSOL($table, (int)$uid);
$selectedRecord['_RECORD_TITLE'] = BackendUtility::getRecordTitle($table, $selectedRecord);
return $selectedRecord;
}
/**
* Reports if the current pad has elements (does not check file/DB type OR if file/DBrecord exists or not. Only counting array)
*
* @return bool TRUE if elements exist.
*/
protected function isElements(): bool
{
return is_array($this->clipData[$this->current]['el'] ?? null) && !empty($this->clipData[$this->current]['el']);
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
/**
* Builds a URL to the current module with the received
* parameters, merged / replaced by additional parameters.
*/
protected function buildUrl(array $parameters = []): string
{
if ($this->request === null) {
throw new \RuntimeException(
'Request object must be set to generate clipboard URL\'s',
1633604720
);
}
return (string)$this->uriBuilder->buildUriFromRequest(
$this->request,
array_replace($this->request->getQueryParams(), $parameters)
);
}
}
+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\Backend\Clipboard\Type;
/**
* Count modes for the clipboard
*
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
enum CountMode
{
case CURRENT;
case ALL;
}
+97
View File
@@ -0,0 +1,97 @@
<?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\CodeEditor;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
/**
* Represents an addon for CodeMirror
* @internal
*/
class Addon
{
protected string $identifier;
protected ?JavaScriptModuleInstruction $module = null;
protected ?JavaScriptModuleInstruction $keymap = null;
/**
* @var array
*/
protected $modes = [];
/**
* @var array
*/
protected $options = [];
/**
* @var array
*/
protected $cssFiles = [];
public function __construct(
string $identifier,
?JavaScriptModuleInstruction $module = null,
?JavaScriptModuleInstruction $keymap = null
) {
$this->identifier = $identifier;
$this->module = $module;
$this->keymap = $keymap;
}
public function getIdentifier(): string
{
return $this->identifier;
}
public function getModule(): ?JavaScriptModuleInstruction
{
return $this->module;
}
public function getKeymap(): ?JavaScriptModuleInstruction
{
return $this->keymap;
}
public function setOptions(array $options): Addon
{
$this->options = $options;
return $this;
}
public function getOptions(): array
{
return $this->options;
}
public function setCssFiles(array $cssFiles): Addon
{
$this->cssFiles = $cssFiles;
return $this;
}
public function getCssFiles(): array
{
return $this->cssFiles;
}
}
+146
View File
@@ -0,0 +1,146 @@
<?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\CodeEditor;
use TYPO3\CMS\Backend\CodeEditor\Registry\AddonRegistry;
use TYPO3\CMS\Backend\CodeEditor\Registry\ModeRegistry;
use TYPO3\CMS\Core\Cache\CacheManager;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Package\Cache\PackageDependentCacheIdentifier;
use TYPO3\CMS\Core\Package\PackageManager;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Provides necessary code to set up a code editor instance in FormEngine
* @internal
* @todo: refactor to use DI
*/
class CodeEditor implements SingletonInterface
{
protected ?array $configuration = null;
/**
* Registers the configuration and bootstraps the modes / addons.
*
* @throws \InvalidArgumentException
*/
public function registerConfiguration(): void
{
$configuration = $this->buildConfiguration();
if (isset($configuration['modes'])) {
$modeRegistry = GeneralUtility::makeInstance(ModeRegistry::class);
foreach ($configuration['modes'] as $formatCode => $mode) {
$modeInstance = GeneralUtility::makeInstance(Mode::class, $mode['module'])->setFormatCode($formatCode);
if (!empty($mode['extensions']) && is_array($mode['extensions'])) {
$modeInstance->bindToFileExtensions($mode['extensions']);
}
if (isset($mode['default']) && $mode['default'] === true) {
$modeInstance->setAsDefault();
}
$modeRegistry->register($modeInstance);
}
}
$addonRegistry = GeneralUtility::makeInstance(AddonRegistry::class);
if (isset($configuration['addons'])) {
foreach ($configuration['addons'] as $identifier => $addon) {
$addonInstance = GeneralUtility::makeInstance(Addon::class, $identifier, $addon['module'] ?? null, $addon['keymap'] ?? null);
if (!empty($addon['cssFiles']) && is_array($addon['cssFiles'])) {
$addonInstance->setCssFiles($addon['cssFiles']);
}
if (!empty($addon['options']) && is_array($addon['options'])) {
$addonInstance->setOptions($addon['options']);
}
$addonRegistry->register($addonInstance);
}
}
}
/**
* Compiles the configuration for code editor. Configuration is stored in caching framework.
*
* @throws \TYPO3\CMS\Core\Cache\Exception\NoSuchCacheException
* @throws \TYPO3\CMS\Core\Cache\Exception\InvalidDataException
* @throws \InvalidArgumentException
*/
protected function buildConfiguration(): array
{
if ($this->configuration !== null) {
return $this->configuration;
}
$this->configuration = [
'modes' => [],
'addons' => [],
];
$cache = $this->getCache();
$packageManager = GeneralUtility::makeInstance(PackageManager::class);
$cacheIdentifier = $this->generateCacheIdentifier($packageManager);
$configurationFromCache = $cache->get($cacheIdentifier);
if ($configurationFromCache !== false) {
$this->configuration = $configurationFromCache;
} else {
$packages = $packageManager->getActivePackages();
foreach ($packages as $package) {
$configurationPath = $package->getPackagePath() . 'Configuration/Backend/T3editor';
$modesFileNameForPackage = $configurationPath . '/Modes.php';
if (is_file($modesFileNameForPackage)) {
$definedModes = require $modesFileNameForPackage;
if (is_array($definedModes)) {
$this->configuration['modes'] = array_merge($this->configuration['modes'], $definedModes);
}
}
$addonsFileNameForPackage = $configurationPath . '/Addons.php';
if (is_file($addonsFileNameForPackage)) {
$definedAddons = require $addonsFileNameForPackage;
if (is_array($definedAddons)) {
$this->configuration['addons'] = array_merge($this->configuration['addons'], $definedAddons);
}
}
}
$cache->set($cacheIdentifier, $this->configuration);
}
return $this->configuration;
}
protected function generateCacheIdentifier(PackageManager $packageManager): string
{
return (new PackageDependentCacheIdentifier($packageManager))->withPrefix('T3editorConfiguration')->toString();
}
/**
* @throws \TYPO3\CMS\Core\Cache\Exception\NoSuchCacheException
* @throws \InvalidArgumentException
*/
protected function getCache(): FrontendInterface
{
return GeneralUtility::makeInstance(CacheManager::class)->getCache('assets');
}
}
@@ -0,0 +1,26 @@
<?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\CodeEditor\Exception;
use TYPO3\CMS\Core\Exception;
/**
* Exception thrown for invalid modes
* @internal
*/
class InvalidModeException extends Exception {}
+90
View File
@@ -0,0 +1,90 @@
<?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\CodeEditor;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
/**
* Represents a mode for CodeMirror
* @internal
*/
class Mode
{
protected JavaScriptModuleInstruction $module;
/**
* @var string
*/
protected $formatCode = '';
/**
* @var array
*/
protected $fileExtensions = [];
/**
* @var bool
*/
protected $isDefault = false;
public function __construct(JavaScriptModuleInstruction $module)
{
$this->module = $module;
}
public function getModule(): JavaScriptModuleInstruction
{
return $this->module;
}
public function getFormatCode(): string
{
return $this->formatCode;
}
public function setFormatCode(string $formatCode): Mode
{
$this->formatCode = $formatCode;
return $this;
}
public function bindToFileExtensions(array $fileExtensions): Mode
{
$this->fileExtensions = $fileExtensions;
return $this;
}
public function getBoundFileExtensions(): array
{
return $this->fileExtensions;
}
public function setAsDefault(): Mode
{
$this->isDefault = true;
return $this;
}
public function isDefault(): bool
{
return $this->isDefault;
}
}
@@ -0,0 +1,61 @@
<?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\CodeEditor\Registry;
use TYPO3\CMS\Backend\CodeEditor\Addon;
use TYPO3\CMS\Core\SingletonInterface;
/**
* Registers and holds code editor modes
* @internal
*/
class AddonRegistry implements SingletonInterface
{
/**
* @var Addon[]
*/
protected $registeredAddons = [];
/**
* Registers addons for global use in code editor
*/
public function register(Addon $addon): AddonRegistry
{
$this->registeredAddons[] = $addon;
return $this;
}
public function getAddons(): array
{
return $this->registeredAddons;
}
/**
* @param Addon[] $addons
*/
public function compileSettings(array $addons): array
{
$settings = [];
foreach ($addons as $addon) {
$settings = array_merge($settings, $addon->getOptions());
}
return $settings;
}
}
@@ -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\Backend\CodeEditor\Registry;
use TYPO3\CMS\Backend\CodeEditor\Exception\InvalidModeException;
use TYPO3\CMS\Backend\CodeEditor\Mode;
use TYPO3\CMS\Core\SingletonInterface;
/**
* Registers and holds code editor modes
* @internal
*/
class ModeRegistry implements SingletonInterface
{
/**
* @var Mode[]
*/
protected array $registeredModes = [];
protected Mode $defaultMode;
/**
* Registers modes for code editor
*/
public function register(Mode $mode): ModeRegistry
{
$this->registeredModes[$mode->getFormatCode()] = $mode;
if ($mode->isDefault()) {
$this->defaultMode = $mode;
}
return $this;
}
/**
* Removes registered modes
*/
public function unregister(string $formatCode): ModeRegistry
{
if (isset($this->registeredModes[$formatCode])) {
unset($this->registeredModes[$formatCode]);
}
return $this;
}
public function isRegistered(string $formatCode): bool
{
return isset($this->registeredModes[$formatCode]);
}
/**
* @throws InvalidModeException
*/
public function getByFormatCode(string $formatCode): Mode
{
foreach ($this->registeredModes as $mode) {
if ($mode->getFormatCode() === $formatCode) {
return $mode;
}
}
throw new InvalidModeException('Tried to get unregistered code editor mode by format code "' . $formatCode . '"', 1499710203);
}
/**
* @throws InvalidModeException
*/
public function getByFileExtension(string $fileExtension): Mode
{
foreach ($this->registeredModes as $mode) {
if (in_array($fileExtension, $mode->getBoundFileExtensions(), true)) {
return $mode;
}
}
throw new InvalidModeException('Cannot find a registered mode for requested file extension "' . $fileExtension . '"', 1500306488);
}
public function getDefaultMode(): Mode
{
return $this->defaultMode;
}
}
@@ -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;
}
}
@@ -0,0 +1,178 @@
<?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\Configuration;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Convenience wrapper for backend user configuration
*
* @internal
*/
class BackendUserConfiguration
{
/**
* @var BackendUserAuthentication
*/
protected $backendUser;
/**
* @param BackendUserAuthentication|null $backendUser
*/
public function __construct(?BackendUserAuthentication $backendUser = null)
{
$this->backendUser = $backendUser ?: $GLOBALS['BE_USER'];
}
/**
* Returns a specific user setting
*
* @param string $key Identifier, allows also dotted notation for subarrays
* @return mixed Value associated
*/
public function get(string $key)
{
return (str_contains($key, '.')) ? $this->getFromDottedNotation($key) : $this->backendUser->uc[$key];
}
/**
* Get all user settings
*
* @return mixed all values, usually a multi-dimensional array
*/
public function getAll()
{
return $this->backendUser->uc;
}
/**
* Sets user settings by key/value pair
*
* @param mixed $value
*/
public function set(string $key, $value): void
{
if (str_contains($key, '.')) {
$this->setFromDottedNotation($key, $value);
} else {
$this->backendUser->uc[$key] = $value;
}
$this->backendUser->writeUC();
}
/**
* Adds a value to a Comma-separated list
* stored in $key of user settings
*
* @param mixed $value
*/
public function addToList(string $key, $value): void
{
$list = $this->get($key);
if (!isset($list)) {
$list = $value;
} elseif (!GeneralUtility::inList($list, $value)) {
$list .= ',' . $value;
}
$this->set($key, $list);
}
/**
* Removes a value from a Comma-separated list
* stored in $key of user settings
*
* @param mixed $value
*/
public function removeFromList(string $key, $value): void
{
$list = $this->get($key);
if (GeneralUtility::inList($list, $value)) {
$list = GeneralUtility::trimExplode(',', $list, true);
$list = ArrayUtility::removeArrayEntryByValue($list, $value);
$this->set($key, implode(',', $list));
}
}
/**
* Resets the user settings to the default
*/
public function clear(): void
{
$this->backendUser->resetUC();
}
/**
* Unsets a key in user settings
*/
public function unsetOption(string $key): void
{
if (isset($this->backendUser->uc[$key])) {
unset($this->backendUser->uc[$key]);
$this->backendUser->writeUC();
}
}
/**
* Computes the subarray from dotted notation
*
* @param string $key Dotted notation of subkeys like moduleData.module1.general.checked
* @return mixed value of the settings
*/
protected function getFromDottedNotation(string $key)
{
$subkeys = GeneralUtility::trimExplode('.', $key);
$configuration = $this->backendUser->uc;
foreach ($subkeys as $subkey) {
if (isset($configuration[$subkey])) {
$configuration = &$configuration[$subkey];
} else {
$configuration = [];
break;
}
}
return $configuration;
}
/**
* Sets the value of a key written in dotted notation
*
* @param mixed $value
*/
protected function setFromDottedNotation(string $key, $value): void
{
$subkeys = GeneralUtility::trimExplode('.', $key, true);
$lastKey = $subkeys[count($subkeys) - 1];
$configuration = &$this->backendUser->uc;
foreach ($subkeys as $subkey) {
if ($subkey === $lastKey) {
$configuration[$subkey] = $value;
} else {
$configuration = &$configuration[$subkey];
}
}
}
}
@@ -0,0 +1,95 @@
<?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\Configuration;
use Symfony\Component\Finder\Finder;
use TYPO3\CMS\Core\Configuration\Tca\TcaMigration;
use TYPO3\CMS\Core\Package\PackageManager;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Helper class for the backend "Sites" module
*
* Load Site configuration TCA from ext:*Configuration/SiteConfiguration
* and ext:*Configuration/SiteConfiguration/Overrides
*
* @internal This class is a specific Backend implementation and is not considered part of the Public TYPO3 API.
*/
class SiteTcaConfiguration
{
/**
* Returns a "fake TCA" array that is syntactically identical to
* "normal" TCA, but is not available as $GLOBALS['TCA']. During
* configuration loading time, the target array is available as
* $GLOBALS['SiteConfiguration'] within the Overrides files.
*
* It is not possible to use ExtensionManagementUtility methods.
*/
public function getTca(): array
{
$GLOBALS['SiteConfiguration'] = [];
$activePackages = GeneralUtility::makeInstance(PackageManager::class)->getActivePackages();
// First load "full table" files from Configuration/SiteConfiguration
$finder = (new Finder())->files()->depth(0)->name('*.php');
$hasDirectoryEntries = false;
foreach ($activePackages as $package) {
try {
$finder->in($package->getPackagePath() . 'Configuration/SiteConfiguration');
} catch (\InvalidArgumentException $e) {
// No such directory in this package
continue;
}
$hasDirectoryEntries = true;
}
if ($hasDirectoryEntries) {
foreach ($finder as $fileInfo) {
$GLOBALS['SiteConfiguration'][substr($fileInfo->getBasename(), 0, -4)] = require $fileInfo->getPathname();
}
}
// Execute override files from Configuration/SiteConfiguration/Overrides
$finder = (new Finder())->files()->depth(0)->name('*.php');
$hasDirectoryEntries = false;
foreach ($activePackages as $package) {
try {
$finder->in($package->getPackagePath() . 'Configuration/SiteConfiguration/Overrides');
} catch (\InvalidArgumentException $e) {
// No such directory in this package
continue;
}
$hasDirectoryEntries = true;
}
if ($hasDirectoryEntries) {
foreach ($finder as $fileInfo) {
require $fileInfo->getPathname();
}
}
$result = $GLOBALS['SiteConfiguration'];
unset($GLOBALS['SiteConfiguration']);
$tcaMigration = GeneralUtility::makeInstance(TcaMigration::class);
$tcaProcessingResult = $tcaMigration->migrate($result);
$messages = $tcaProcessingResult->getMessages();
if (!empty($messages)) {
$context = 'Automatic TCA migration done during bootstrap of Site TCA Configuration.'
. ' Please adapt TCA accordingly, these migrations will be removed.'
. ' Please adapt these areas:';
array_unshift($messages, $context);
trigger_error(implode(LF, $messages), E_USER_DEPRECATED);
}
return $tcaProcessingResult->getTca();
}
}
@@ -0,0 +1,164 @@
<?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\Configuration\TCA;
use TYPO3\CMS\Core\Country\Country;
use TYPO3\CMS\Core\Country\CountryFilter;
use TYPO3\CMS\Core\Country\CountryProvider;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* This class provides items processor functions for the usage in TCA definition
* @internal
*/
class ItemsProcessorFunctions
{
/**
* Return languages found in already existing site configurations,
* sorted by their value. In case the same language is used with
* different titles, they will be added to the items label field.
* Additionally, a placeholder value is added to allow the creation
* of new site languages.
*/
public function populateAvailableLanguagesFromSites(array &$fieldDefinition): void
{
foreach (GeneralUtility::makeInstance(SiteFinder::class)->getAllSites() as $site) {
foreach ($site->getAllLanguages() as $languageId => $language) {
if (!isset($fieldDefinition['items'][$languageId])) {
$fieldDefinition['items'][$languageId] = [
'label' => $language->getTitle(),
'value' => $languageId,
'icon' => $language->getFlagIdentifier(),
'tempTitles' => [],
];
} elseif ($fieldDefinition['items'][$languageId]['label'] !== $language->getTitle()) {
// Temporarily store different titles
$fieldDefinition['items'][$languageId]['tempTitles'][] = $language->getTitle();
}
}
}
if (!isset($fieldDefinition['items'][0])) {
// Since TcaSiteLanguage has a special behaviour, enforcing the
// default language ("0") to be always added to the site configuration,
// we have to add it to the available items, in case it is not already
// present. This only happens for the first ever created site configuration.
$fieldDefinition['items'][] = ['label' => 'Default', 'value' => 0, 'icon' => '', 'tempTitles' => []];
}
ksort($fieldDefinition['items']);
// Build the final language label
foreach ($fieldDefinition['items'] as &$language) {
$language['label'] .= ' [' . $language['value'] . ']';
if ($language['tempTitles'] !== []) {
$language['label'] .= ' (' . implode(',', array_unique($language['tempTitles'])) . ')';
// Unset the temporary title "storage"
unset($language['tempTitles']);
}
}
unset($language);
// Add PHP_INT_MAX as last - placeholder - value to allow creation of new records
// with the "Create new" button, which is usually not possible in "selector" mode.
// Note: The placeholder will never be displayed in the selector.
$fieldDefinition['items'] = array_values(
array_merge($fieldDefinition['items'], [['label' => 'Placeholder', 'value' => PHP_INT_MAX]])
);
}
/**
* Return language items for use in site_languages.fallbacks
*/
public function populateFallbackLanguages(array &$fieldDefinition): void
{
foreach (GeneralUtility::makeInstance(SiteFinder::class)->getAllSites() as $site) {
foreach ($site->getAllLanguages() as $languageId => $language) {
if (isset($fieldDefinition['row']['languageId'][0])
&& (int)$fieldDefinition['row']['languageId'][0] === $languageId
) {
// Skip current language id
continue;
}
if (!isset($fieldDefinition['items'][$languageId])) {
$fieldDefinition['items'][$languageId] = [
'label' => $language->getTitle(),
'value' => $languageId,
'icon' => $language->getFlagIdentifier(),
'tempTitles' => [],
];
} elseif ($fieldDefinition['items'][$languageId]['label'] !== $language->getTitle()) {
// Temporarily store different titles
$fieldDefinition['items'][$languageId]['tempTitles'][] = $language->getTitle();
}
}
}
ksort($fieldDefinition['items']);
// Build the final language label
foreach ($fieldDefinition['items'] as &$language) {
if ($language['tempTitles'] !== []) {
$language['label'] .= ' (' . implode(',', array_unique($language['tempTitles'])) . ')';
// Unset the temporary title "storage"
unset($language['tempTitles']);
}
}
unset($language);
$fieldDefinition['items'] = array_values($fieldDefinition['items']);
}
public function populateFlags(array &$fieldConfiguration): void
{
$filter = (new CountryFilter())->setExcludeCountries(['um']);
$countries = GeneralUtility::makeInstance(CountryProvider::class)->getFiltered($filter);
/** @var Country $country */
foreach ($countries as $country) {
$code = strtolower($country->getAlpha2IsoCode());
$fieldConfiguration['items'][] = [
'label' => $country->getName(),
'value' => $code,
'icon' => 'flags-' . $code,
'group' => 'countries',
];
}
// Additional country variants
$variants = ['ca-qc', 'es-ct', 'es-ga', 'gb-eng', 'gb-nir', 'gb-sct', 'gb-wls'];
foreach ($variants as $variant) {
$split = explode('-', $variant);
$base = $countries[strtoupper($split[0])];
$fieldConfiguration['items'][] = [
'label' => sprintf('%s - %s', $base->getName(), strtoupper($split[1])),
'value' => $variant,
'icon' => 'flags-' . $variant,
'group' => 'countries',
];
}
$colors = ['black', 'white', 'blue', 'indigo', 'purple', 'pink', 'orange', 'yellow', 'green', 'teal', 'cyan', 'rainbow'];
foreach ($colors as $color) {
$fieldConfiguration['items'][] = [
'label' => $color,
'value' => $color,
'icon' => 'flags-' . $color,
'group' => 'colors',
];
}
}
}
+120
View File
@@ -0,0 +1,120 @@
<?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\Configuration\TCA;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Localization\Locales;
/**
* This class provides user functions for the usage in TCA definition
* @internal
*/
class UserFunctions
{
/**
* Used to build the IRRE title of a site language element
*/
public function getSiteLanguageTitle(array &$parameters): void
{
$record = $parameters['row'];
$languageId = (int)($record['languageId'][0] ?? 0);
if ($languageId === PHP_INT_MAX && str_starts_with((string)($record['uid'] ?? ''), 'NEW')) {
// If we deal with a new record, created via "Create new" (indicated by the PHP_INT_MAX placeholder),
// we use a label as record title, until the real values, especially the language ID, are calculated.
$parameters['title'] = '[' . $this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration_tca.xlf:site.languages.new') . ']';
return;
}
$primaryValue = $record['primary'] ?? null;
$isPrimary = false;
if (is_array($primaryValue)) {
$isPrimary = !empty($primaryValue[0]);
} elseif ($primaryValue !== null) {
$isPrimary = (bool)$primaryValue;
}
if (!$isPrimary) {
$isPrimary = ((int)($record['languageId'][0] ?? -1) === 0);
}
$parameters['title'] = sprintf(
'%s %s [%d] (%s) Base: %s%s',
$record['enabled'] ? '' : '[' . $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:disabled') . ']',
$record['title'],
$languageId,
$record['locale'],
$record['base'],
$isPrimary ? ' ★' : ''
);
}
/**
* Used to build the IRRE title of a site route element
*/
public function getRouteTitle(array &$parameters): void
{
$record = $parameters['row'];
if (($record['type'][0] ?? false) === 'uri') {
$parameters['title'] = sprintf(
'%s %s %s',
$record['route'],
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_siteconfiguration_tca.xlf:site.routes.irreHeader.redirectsTo'),
$record['source'] ?: '[' . $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:undefined') . ']'
);
} else {
$parameters['title'] = $record['route'];
}
}
/**
* Used to build the IRRE title of a site error handling element
*/
public function getErrorHandlingTitle(array &$parameters): void
{
$record = $parameters['row'];
$format = '%s: %s';
$arguments = [$record['errorCode']];
switch ($record['errorHandler'][0] ?? false) {
case 'Fluid':
$arguments[] = $record['errorFluidTemplate'];
break;
case 'Page':
$arguments[] = $record['errorContentSource'];
break;
case 'PHP':
$arguments[] = $record['errorPhpClassFQCN'];
break;
default:
$arguments[] = $record['errorHandler'][0] ?? '';
}
$parameters['title'] = sprintf($format, ...$arguments);
}
public static function getAllSystemLocales(): array
{
$locales = [];
foreach (Locales::getAllSystemLocales() as $locale) {
$locales[] = ['label' => $locale, 'value' => $locale];
}
return $locales;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,245 @@
<?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\Configuration;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DeletedRestriction;
use TYPO3\CMS\Core\Database\Query\Restriction\WorkspaceRestriction;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Site\Entity\NullSite;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Contains translation tools
*
* @phpstan-type LanguageRef -1|0|positive-int
* @internal The whole class is subject to be removed, fetch all language info from the current site object.
*/
#[Autoconfigure(public: true)]
readonly class TranslationConfigurationProvider
{
public function __construct(
#[Autowire(service: 'cache.runtime')]
private FrontendInterface $runtimeCache,
private SiteFinder $siteFinder,
private ConnectionPool $connectionPool,
private TcaSchemaFactory $tcaSchemaFactory,
) {}
/**
* Returns array of languages given for a specific site (or "nullSite" if on page=0)
* The property flagIcon returns a string <flags-xx>.
*
* @param int $pageId Page id (used to get TSconfig configuration setting flag and label for default language)
* @return array<LanguageRef, array{uid: int, title: string, ISOcode: string, flagIcon: string}> Array with languages
*/
public function getSystemLanguages(int $pageId = 0): array
{
$cacheKey = 'system-language-cache-page-uid-' . $pageId;
if ($this->runtimeCache->has($cacheKey)) {
return $this->runtimeCache->get($cacheKey);
}
$allSystemLanguages = [];
if ($pageId === 0) {
// Used for e.g. filelist, where there is no site selected.
// This also means that there is no "-1" (All Languages) selectable.
// Languages are consolidated across all sites with unique titles.
$sites = $this->siteFinder->getAllSites();
foreach ($sites as $site) {
$this->addSiteLanguagesToConsolidatedList(
$allSystemLanguages,
$site->getAvailableLanguages($this->getBackendUserAuthentication()),
);
}
$this->computeSystemLanguagesTitleAndFlag($allSystemLanguages, true);
} else {
try {
$site = $this->siteFinder->getSiteByPageId($pageId);
} catch (SiteNotFoundException) {
$site = new NullSite();
}
$siteLanguages = $site->getAvailableLanguages($this->getBackendUserAuthentication(), true);
if (!isset($siteLanguages[0])) {
$siteLanguages[0] = $site->getDefaultLanguage();
}
$this->addSiteLanguagesToConsolidatedList($allSystemLanguages, $siteLanguages);
$this->computeSystemLanguagesTitleAndFlag($allSystemLanguages);
}
ksort($allSystemLanguages);
$this->runtimeCache->set($cacheKey, $allSystemLanguages);
return $allSystemLanguages;
}
protected function addSiteLanguagesToConsolidatedList(array &$allSystemLanguages, array $languagesOfSpecificSite): void
{
foreach ($languagesOfSpecificSite as $language) {
$languageId = $language->getLanguageId();
$allSystemLanguages[$languageId] ??= [
'uid' => $languageId,
'titlesMap' => [],
'flagsMap' => [],
];
$allSystemLanguages[$languageId]['titlesMap'][$language->getTitle()] = true;
$allSystemLanguages[$languageId]['flagsMap'][$language->getFlagIdentifier()] = true;
}
}
protected function computeSystemLanguagesTitleAndFlag(array &$allSystemLanguages, bool $showIdInTitle = false): void
{
foreach ($allSystemLanguages as &$language) {
$language['title'] = implode(', ', array_keys($language['titlesMap']));
if ($language['uid'] === 0 && count($language['titlesMap']) > 1) {
// "Default" label for language 0 with multiple titles.
$language['title'] = $this->getLanguageService()->translate('LGL.defaultLanguage', 'core.general');
}
if ($showIdInTitle) {
$language['title'] .= ' [' . $language['uid'] . ']';
}
$language['flagIcon'] = array_key_first($language['flagsMap']);
if (count($language['titlesMap']) > 1 || count($language['flagsMap']) > 1) {
$language['flagIcon'] = 'flags-multiple';
}
unset($language['titlesMap'], $language['flagsMap']);
}
}
/**
* Information about translation for an element
* Will overlay workspace version of record too!
*
* @param string $table Table name
* @param int $uid Record uid
* @param int $languageUid Language uid. If 0, then all languages are selected.
* @param array|null $row The record to be translated
* @param array|string $selFieldList Select fields for the query which fetches the translations of the current record
* @return array|string Array with information or error message as a string.
*/
public function translationInfo($table, $uid, $languageUid = 0, ?array $row = null, $selFieldList = ''): array|string
{
if (!$this->tcaSchemaFactory->has($table) || !$uid) {
return 'No table "' . $table . '" or no UID value';
}
$schema = $this->tcaSchemaFactory->get($table);
if (!$schema->isLanguageAware()) {
return 'Translation is not supported for this table!';
}
if ($row === null) {
$row = BackendUtility::getRecordWSOL($table, $uid);
}
if (!is_array($row)) {
return 'Record "' . $table . '_' . $uid . '" was not found';
}
$languageCapability = $schema->getCapability(TcaSchemaCapability::Language);
$languageFieldName = $languageCapability->getLanguageField()->getName();
$translationOriginPointerFieldName = $languageCapability->getTranslationOriginPointerField()->getName();
if ($row[$languageFieldName] > 0) {
return 'Record "' . $table . '_' . $uid . '" seems to be a translation already (has a language value "' . $row[$languageFieldName] . '", relation to record "' . $row[$translationOriginPointerFieldName] . '")';
}
if ($row[$translationOriginPointerFieldName] != 0) {
return 'Record "' . $table . '_' . $uid . '" seems to be a translation already (has a relation to record "' . $row[$translationOriginPointerFieldName] . '")';
}
// Look for translations of this record, index by language field value:
if (!empty($selFieldList)) {
if (is_array($selFieldList)) {
$selectFields = $selFieldList;
} else {
$selectFields = GeneralUtility::trimExplode(',', $selFieldList);
}
} else {
$selectFields = ['uid', $languageFieldName];
}
$queryBuilder = $this->connectionPool->getQueryBuilderForTable($table);
$queryBuilder->getRestrictions()
->removeAll()
->add(GeneralUtility::makeInstance(DeletedRestriction::class))
->add(GeneralUtility::makeInstance(WorkspaceRestriction::class, $this->getBackendUserAuthentication()->workspace));
$queryBuilder
->select(...$selectFields)
->from($table)
->where(
$queryBuilder->expr()->eq(
$translationOriginPointerFieldName,
$queryBuilder->createNamedParameter($uid, Connection::PARAM_INT)
),
$queryBuilder->expr()->eq(
'pid',
$queryBuilder->createNamedParameter(
$row['pid'],
Connection::PARAM_INT
)
)
);
if (!$languageUid) {
$queryBuilder->andWhere(
$queryBuilder->expr()->gt(
$languageFieldName,
$queryBuilder->createNamedParameter(0, Connection::PARAM_INT)
)
);
} else {
$queryBuilder
->andWhere(
$queryBuilder->expr()->eq(
$languageFieldName,
$queryBuilder->createNamedParameter($languageUid, Connection::PARAM_INT)
)
);
}
$translationRecords = $queryBuilder->executeQuery()->fetchAllAssociative();
$translations = [];
$translationsErrors = [];
foreach ($translationRecords as $translationRecord) {
if (!isset($translations[$translationRecord[$languageFieldName]])) {
$translations[$translationRecord[$languageFieldName]] = $translationRecord;
} else {
$translationsErrors[$translationRecord[$languageFieldName]][] = $translationRecord;
}
}
return [
'table' => $table,
'uid' => $uid,
'CType' => $row['CType'] ?? '',
'sys_language_uid' => $row[$languageFieldName] ?? null,
'translations' => $translations,
'excessive_translations' => $translationsErrors,
];
}
protected function getBackendUserAuthentication(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+151
View File
@@ -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\Backend\Context;
use TYPO3\CMS\Backend\Domain\Model\Language\PageLanguageInformation;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
/**
* Generic page context for all backend modules working with pages ("id" parameter and page-tree navigation component).
*
* This context is added to the backend request by the PSR-15 "PageContextInitialization" middleware.
* Replaces the module-specific duplication of language handling, page information, and site context.
*
* Contains shared data needed across Layout Module, Records Module, etc.
* Does NOT contain module-specific rendering configuration.
*
* This is a DOMAIN object and should NOT contain HTTP infrastructure concerns like ServerRequestInterface.
*
* Access Handling:
* If the user has no access to the requested page, pageRecord will be null.
* Controllers should check $pageContext->isAccessible() before processing.
*
* Usage:
* $pageContext = $request->getAttribute('pageContext');
* if (!$pageContext->isAccessible()) {
* // Show no access page
* return $view->renderResponse('NoAccess');
* }
* $selectedLanguages = $pageContext->selectedLanguageIds;
* $languageInfo = $pageContext->languageInformation;
* $rootLine = $pageContext->rootLine;
* $pageTsConfig = $pageContext->pageTsConfig;
* $moduleTsConfig = $pageContext->getModuleTsConfig('web_layout');
*
* @internal
*/
final readonly class PageContext
{
/**
* @param int $pageId Page ID (always preserved, even if no access)
* @param ?array $pageRecord Page record from readPageAccess (null if no access)
* @param int[] $selectedLanguageIds Selected language IDs (resolved and validated)
* @param PageLanguageInformation $languageInformation Complete language information for this page
* @param array $rootLine Page rootline including the page itself (empty array if no access)
* @param array $pageTsConfig PageTSconfig array (dots removed, overlaid by user permissions, falls back to page 0 if no access)
* @param Permission $pagePermissions User's permissions for this page (calculated from backendUser->calcPerms)
*/
public function __construct(
public int $pageId,
public ?array $pageRecord,
public SiteInterface $site,
public array $rootLine,
public array $pageTsConfig,
public array $selectedLanguageIds,
public PageLanguageInformation $languageInformation,
public Permission $pagePermissions,
) {}
/**
* Check if user has access to the page.
*
* Returns false if user has no access to the requested page.
* Controllers should check this before processing page-specific operations.
*/
public function isAccessible(): bool
{
return $this->pageRecord !== null && $this->pagePermissions->showPagePermissionIsGranted();
}
/**
* Get primary selected language for single-language views.
*
* Logic:
* - If exactly 1 non-default language is selected → use that translation
* - If 0 or 2+ non-default languages are selected → use default (0)
*
* This ensures that when switching from multi-language to single-language view,
* the user's focused translation is preserved (when they had one selected).
*
* @return int Primary language ID
*/
public function getPrimaryLanguageId(): int
{
$nonDefaultLanguages = array_filter($this->selectedLanguageIds, static fn(int $id): bool => $id > 0);
if (count($nonDefaultLanguages) === 1) {
return reset($nonDefaultLanguages);
}
return 0;
}
/**
* Check if multiple languages are currently selected.
*
* This is useful for determining if comparison/multi-column view should be shown.
*/
public function hasMultipleLanguagesSelected(): bool
{
return count($this->selectedLanguageIds) > 1;
}
public function isLanguageSelected(int $languageId): bool
{
return in_array($languageId, $this->selectedLanguageIds, true);
}
public function isDefaultLanguageSelected(): bool
{
return $this->isLanguageSelected(0);
}
/**
* Get page title (localized if translation exists).
*
* @param int|null $languageId Language ID (null = primary selected language)
*/
public function getPageTitle(?int $languageId = null): string
{
$languageId ??= $this->getPrimaryLanguageId();
if ($languageId === 0) {
return $this->pageRecord['title'] ?? '';
}
$translation = $this->languageInformation->getTranslationRecord($languageId);
return $translation['title'] ?? $this->pageRecord['title'] ?? '';
}
/**
* This is a convenience method to easily access mod.{module}.* configuration.
*/
public function getModuleTsConfig(string $module): array
{
return is_array($this->pageTsConfig['mod'][$module] ?? false) ? $this->pageTsConfig['mod'][$module] : [];
}
}
+219
View File
@@ -0,0 +1,219 @@
<?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\Context;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Module\ModuleData;
use TYPO3\CMS\Backend\Service\PageLanguageInformationService;
use TYPO3\CMS\Backend\User\SharedUserPreferences;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Factory for creating PageContext instances.
*
* This is the SINGLE entry point for creating page contexts across all backend modules.
* It centralizes the logic for:
* - Resolving language selection with fallback chain
* - Validating languages against available languages
* - Permission checks
* - Fetching language information
*
* @internal
*/
final readonly class PageContextFactory
{
public function __construct(
private SharedUserPreferences $sharedPreferences,
private PageLanguageInformationService $languageService,
) {}
/**
* Create PageContext from request and page ID.
*
* This method:
* 1. Validates page access (returns context with null pageRecord if no access)
* 2. Fetches language information for the page
* 3. Resolves selected languages with fallback chain
* 4. Validates selected languages against existing translations on this page
* 5. Falls back to default language if no valid languages selected
* 6. Stores preference if explicitly changed via request (preserves across pages)
* 7. Creates and returns the PageContext
*
* Language validation ensures that only languages with actual translations on
* the current page are included in selectedLanguageIds. This guarantees that
* getPrimaryLanguageId() always returns a valid language for the current page.
*
* User preferences are preserved: selecting L=1 on PageA stores the preference,
* navigating to PageB without L=1 shows L=0, returning to PageA restores L=1.
*
* Access Handling:
* If the user has no access to the requested page or pid=0, a PageContext is still returned,
* while pageRecord mit be null if no access. Controllers should check isAccessible().
*
* @param int $pageId Page ID to create context for
*/
public function createFromRequest(
ServerRequestInterface $request,
int $pageId,
BackendUserAuthentication $backendUser
): PageContext {
$site = $request->getAttribute('site');
if (!$site instanceof SiteInterface) {
throw new SiteNotFoundException('No site found in request', 1731234567);
}
// Check page access
$pageRecord = BackendUtility::readPageAccess($pageId, $backendUser->getPagePermsClause(Permission::PAGE_SHOW)) ?: null;
if ($pageId === 0 || !$pageRecord) {
// Either root page (pid=0) which has no real page record or no access.
// Return context with preserved pageId.
// pageRecord might be ['path' => '/'] for admins or NULL if no access or non-admin
// Still calculate permissions (admins have access to pid=0, editors don't).
return new PageContext(
pageId: $pageId,
pageRecord: $pageRecord,
site: $site,
rootLine: [],
pageTsConfig: GeneralUtility::removeDotsFromTS(BackendUtility::getPagesTSconfig(0)),
selectedLanguageIds: [0],
languageInformation: $this->languageService->getLanguageInformationForPage(0, $site, $backendUser),
pagePermissions: new Permission($backendUser->calcPerms($pageRecord ?: ['uid' => 0])),
);
}
// Get language information FIRST (needed for validation)
$languageInformation = $this->languageService->getLanguageInformationForPage($pageId, $site, $backendUser);
// Resolve languages with fallback chain
$languagesFromRequest = $request->getQueryParams()['languages'] ?? $request->getParsedBody()['languages'] ?? null;
// Extract ModuleData languages (with backward compat for old 'language' parameter)
$moduleData = $request->getAttribute('moduleData');
$moduleDataLanguages = null;
if ($moduleData instanceof ModuleData) {
$moduleDataLanguages = $moduleData->get('languages');
// Backward compatibility: convert old 'language' (single int) to 'languages' (array)
if ($moduleDataLanguages === null) {
$oldLanguage = $moduleData->get('language');
if ($oldLanguage !== null) {
$moduleDataLanguages = [(int)$oldLanguage];
}
}
}
// Use SharedUserPreferences fallback chain (page-specific > ModuleData > default)
// This ensures page-specific preferences are shared across modules
$resolvedLanguages = $this->sharedPreferences->resolveLanguages(
$backendUser,
$languagesFromRequest,
$pageId,
$moduleDataLanguages
);
// Validate against existing translations on this page (ensures getPrimaryLanguageId() is valid)
// Preference is preserved across navigation (only stored when explicitly changed via request)
$existingLanguageIds = $languageInformation->getAllExistingLanguageIds();
$validLanguages = array_intersect($resolvedLanguages, $existingLanguageIds);
// Ensure at least default language if none are valid
if (empty($validLanguages)) {
$validLanguages = [0];
}
$validLanguages = array_values($validLanguages);
// Store preference in SharedUserPreferences when explicitly changed via request
if ($languagesFromRequest !== null) {
$this->sharedPreferences->setPageLanguages($backendUser, $pageId, $validLanguages);
}
// Also update ModuleData if present (for backward compatibility and UI state)
if ($moduleData instanceof ModuleData) {
$moduleData->set('languages', $validLanguages);
}
// Create full PageContext for resolved page record
return new PageContext(
pageId: $pageId,
pageRecord: $pageRecord,
site: $site,
rootLine: BackendUtility::BEgetRootLine($pageId),
pageTsConfig: GeneralUtility::removeDotsFromTS(BackendUtility::getPagesTSconfig($pageId)),
selectedLanguageIds: $validLanguages,
languageInformation: $languageInformation,
pagePermissions: new Permission($backendUser->calcPerms($pageRecord)),
);
}
/**
* Create PageContext with specific languages (no fallback resolution).
*
* This is useful for testing or to explicitly set languages
* without going through the fallback chain.
*
* Access Handling:
* If the user has no access to the requested page or pid=0, a PageContext is still returned,
* while pageRecord mit be null if no access. Controllers should check isAccessible().
*/
public function createWithLanguages(
ServerRequestInterface $request,
int $pageId,
array $languageIds,
BackendUserAuthentication $backendUser
): PageContext {
$site = $request->getAttribute('site');
if (!$site instanceof SiteInterface) {
throw new SiteNotFoundException('No site found in request', 1731234569);
}
$pageRecord = BackendUtility::readPageAccess($pageId, $backendUser->getPagePermsClause(Permission::PAGE_SHOW)) ?: null;
if ($pageId === 0 || !$pageRecord) {
// Either root page (pid=0) which has no real page record or no access.
// Return context with preserved pageId.
// pageRecord might be ['path' => '/'] for admins or NULL if no access or non-admin
// Still calculate permissions (admins have access to pid=0, editors don't).
return new PageContext(
pageId: $pageId,
pageRecord: $pageRecord,
site: $site,
rootLine: [],
pageTsConfig: GeneralUtility::removeDotsFromTS(BackendUtility::getPagesTSconfig(0)),
selectedLanguageIds: array_map('intval', $languageIds),
languageInformation: $this->languageService->getLanguageInformationForPage(0, $site, $backendUser),
pagePermissions: new Permission($backendUser->calcPerms($pageRecord ?: ['uid' => 0])),
);
}
// Create full PageContext for resolved page record
return new PageContext(
pageId: $pageId,
pageRecord: $pageRecord,
site: $site,
rootLine: BackendUtility::BEgetRootLine($pageId),
pageTsConfig: GeneralUtility::removeDotsFromTS(BackendUtility::getPagesTSconfig($pageId)),
selectedLanguageIds: array_map('intval', $languageIds),
languageInformation: $this->languageService->getLanguageInformationForPage($pageId, $site, $backendUser),
pagePermissions: new Permission($backendUser->calcPerms($pageRecord)),
);
}
}
+123
View File
@@ -0,0 +1,123 @@
<?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\ContextMenu;
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
use TYPO3\CMS\Backend\ContextMenu\ItemProviders\ItemProvidersRegistry;
use TYPO3\CMS\Backend\ContextMenu\ItemProviders\ProviderInterface;
/**
* Class for generating the click menu
* @internal
*/
#[Autoconfigure(public: true)]
class ContextMenu
{
protected ItemProvidersRegistry $itemProvidersRegistry;
public function __construct(ItemProvidersRegistry $itemProvidersRegistry)
{
$this->itemProvidersRegistry = $itemProvidersRegistry;
}
public function getItems(string $table, string $identifier, string $context = ''): array
{
$items = [];
foreach ($this->getAvailableProviders($table, $identifier, $context) as $provider) {
$items = $provider->addItems($items);
}
return $this->cleanItems($items);
}
/**
* @return ProviderInterface[]
*/
protected function getAvailableProviders(string $table, string $identifier, string $context): array
{
$providers = $this->itemProvidersRegistry->getItemProviders();
$availableProviders = [];
foreach ($providers as $provider) {
$provider->setContext($table, $identifier, $context);
if ($provider->canHandle()) {
$priority = $provider->getPriority();
$availableProviders[$priority] = $provider;
}
}
krsort($availableProviders);
return $availableProviders;
}
/**
* Clean up double dividers.
* Don't render menu when there are no item or submenu.
*/
protected function cleanItems(array $items): array
{
$canRender = false;
$prevItemWasDivider = false;
foreach ($items as $key => $item) {
// Assign the key as the identifier for each item.
// This is needed for the JavaScript to render a single node
$items[$key]['identifier'] = $key;
if ($item['type'] === 'item') {
$canRender = true;
$prevItemWasDivider = false;
continue;
}
if ($item['type'] === 'divider') {
if ($prevItemWasDivider === true) {
unset($items[$key]);
} else {
$prevItemWasDivider = true;
}
continue;
}
if ($item['type'] === 'submenu') {
$childItems = $this->cleanItems($item['childItems']);
if (empty($childItems)) {
unset($items[$key]);
} else {
$items[$key]['childItems'] = $childItems;
$canRender = true;
$prevItemWasDivider = false;
}
continue;
}
}
if ($canRender) {
//Remove first and last divider
$fistItem = reset($items);
if ($fistItem['type'] === 'divider') {
$key = key($items);
unset($items[$key]);
}
$lastItem = end($items);
if ($lastItem['type'] === 'divider') {
$key = key($items);
unset($items[$key]);
}
} else {
//no menu when there are no item or submenu
$items = [];
}
return $items;
}
}
@@ -0,0 +1,210 @@
<?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\ContextMenu\ItemProviders;
use TYPO3\CMS\Backend\Clipboard\Clipboard;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Abstract provider is a base class for context menu item providers
*/
class AbstractProvider implements ProviderInterface
{
/**
* Language Service property. Used to access localized labels
*
* @var LanguageService
*/
protected $languageService;
/**
* @var BackendUserAuthentication
*/
protected $backendUser;
/**
* @var \TYPO3\CMS\Backend\Clipboard\Clipboard
*/
protected $clipboard;
/**
* Array of items the class is providing
*
* @var array
*/
protected $itemsConfiguration = [];
/**
* Click menu items disabled by TSConfig
*
* @var array
*/
protected $disabledItems = [];
/**
* Current table name
*
* @var string
*/
protected $table = '';
/**
* @var string clicked record identifier (usually uid or file combined identifier)
*/
protected $identifier = '';
/**
* Context - from where the click menu was triggered (e.g. 'tree')
*
* @var string
*/
protected $context = '';
public function __construct()
{
$this->languageService = $GLOBALS['LANG'];
$this->backendUser = $GLOBALS['BE_USER'];
}
public function setContext(string $table, string $identifier, string $context = ''): void
{
$this->table = $table;
$this->identifier = $identifier;
$this->context = $context;
}
/**
* Provider initialization, heavy stuff
*/
protected function initialize()
{
$this->initClipboard();
$this->initDisabledItems();
}
/**
* Returns the provider priority which is used for determining the order in which providers are adding items
* to the result array. Highest priority means provider is evaluated first.
*/
public function getPriority(): int
{
return 100;
}
/**
* Whether this provider can handle given request (usually a check based on table, uid and context)
*/
public function canHandle(): bool
{
return false;
}
/**
* Initialize clipboard object - necessary for all copy/cut/paste operations
*/
protected function initClipboard()
{
$clipboard = GeneralUtility::makeInstance(Clipboard::class);
$clipboard->initializeClipboard();
// This locks the clipboard to the Normal for this request.
$clipboard->lockToNormal();
// This removes all no longer existing elements
$clipboard->cleanCurrent();
// This stores the changed clipboard data
$clipboard->endClipboard();
$this->clipboard = $clipboard;
}
/**
* Fills $this->disabledItems with the values from TSConfig.
* Disabled items can be set separately for each context.
*/
protected function initDisabledItems()
{
if ($this->context) {
$tsConfigValue = $this->backendUser->getTSConfig()['options.']['contextMenu.']['table.'][$this->table . '.'][$this->context . '.']['disableItems'] ?? '';
} else {
$tsConfigValue = $this->backendUser->getTSConfig()['options.']['contextMenu.']['table.'][$this->table . '.']['disableItems'] ?? '';
}
$this->disabledItems = GeneralUtility::trimExplode(',', $tsConfigValue, true);
}
/**
* Adds new items to the given array or modifies existing items
*/
public function addItems(array $items): array
{
$this->initialize();
$items += $this->prepareItems($this->itemsConfiguration);
return $items;
}
/**
* Converts item configuration (from $this->itemsConfiguration) into an array ready for returning by controller
*/
protected function prepareItems(array $itemsConfiguration): array
{
$iconFactory = GeneralUtility::makeInstance(IconFactory::class);
$items = [];
foreach ($itemsConfiguration as $name => $configuration) {
$type = !empty($configuration['type']) ? $configuration['type'] : 'item';
if ($this->canRender($name, $type)) {
$items[$name] = [
'type' => $type,
'label' => !empty($configuration['label']) ? htmlspecialchars($this->languageService->sL($configuration['label'])) : '',
'icon' => !empty($configuration['iconIdentifier']) ? $iconFactory->getIcon($configuration['iconIdentifier'], IconSize::SMALL)->render('inline') : '',
'additionalAttributes' => $this->getAdditionalAttributes($name),
'callbackAction' => !empty($configuration['callbackAction']) ? $configuration['callbackAction'] : '',
];
if ($type === 'submenu') {
$items[$name]['childItems'] = $this->prepareItems($configuration['childItems']);
}
}
}
return $items;
}
/**
* Returns an array of additional attributes for given item. Additional attributes are used to pass item specific data
* to the JS. E.g. message for the delete confirmation dialog
*/
protected function getAdditionalAttributes(string $itemName): array
{
return [];
}
/**
* Checks whether certain item can be rendered (e.g. check for disabled items or permissions)
*/
protected function canRender(string $itemName, string $type): bool
{
return true;
}
/**
* Returns a clicked record identifier
*/
protected function getIdentifier(): string
{
return '';
}
}
@@ -0,0 +1,47 @@
<?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\ContextMenu\ItemProviders;
/**
* Registry class for context menu item provider
*
* @internal
*/
class ItemProvidersRegistry
{
protected array $itemProviders = [];
public function __construct(iterable $itemProviders)
{
foreach ($itemProviders as $itemProvider) {
if ($itemProvider instanceof ProviderInterface) {
$this->itemProviders[] = $itemProvider;
}
}
}
/**
* Get all registered item providers
*
* @return ProviderInterface[]
*/
public function getItemProviders(): array
{
return $this->itemProviders;
}
}
@@ -0,0 +1,561 @@
<?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\ContextMenu\ItemProviders;
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Core\DataHandling\PageDoktypeRegistry;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Context menu item provider for pages table
*/
class PageProvider extends RecordProvider
{
/**
* @var string
*/
protected $table = 'pages';
/**
* @var array
*/
protected $itemsConfiguration = [
'view' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.view',
'iconIdentifier' => 'actions-view-page',
'callbackAction' => 'viewRecord',
],
'edit' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.edit',
'iconIdentifier' => 'actions-page-open',
'callbackAction' => 'editRecord',
],
'new' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.newSubpage',
'iconIdentifier' => 'actions-page-new',
'callbackAction' => 'newRecord',
],
'info' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.info',
'iconIdentifier' => 'actions-document-info',
'callbackAction' => 'openInfoPopUp',
],
'qrcode' => [
'label' => 'LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:showPageQrCode',
'iconIdentifier' => 'actions-qrcode',
'callbackAction' => 'showQrCode',
],
'divider1' => [
'type' => 'divider',
],
'copy' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.copy',
'iconIdentifier' => 'actions-edit-copy',
'callbackAction' => 'copy',
],
'copyRelease' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.copy',
'iconIdentifier' => 'actions-edit-copy-release',
'callbackAction' => 'clipboardRelease',
],
'cut' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.cut',
'iconIdentifier' => 'actions-edit-cut',
'callbackAction' => 'cut',
],
'cutRelease' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.cutrelease',
'iconIdentifier' => 'actions-edit-cut-release',
'callbackAction' => 'clipboardRelease',
],
'pasteAfter' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.pasteafter',
'iconIdentifier' => 'actions-document-paste-after',
'callbackAction' => 'pasteAfter',
],
'pasteInto' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.pasteinto',
'iconIdentifier' => 'actions-document-paste-into',
'callbackAction' => 'pasteInto',
],
'divider2' => [
'type' => 'divider',
],
'more' => [
'type' => 'submenu',
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.more',
'iconIdentifier' => '',
'callbackAction' => 'openSubmenu',
'childItems' => [
'pagesSort' => [
'label' => 'LLL:EXT:backend/Resources/Private/Language/locallang_pages_sort.xlf:title',
'iconIdentifier' => 'actions-page-move',
'callbackAction' => 'pagesSort',
],
'pagesNewMultiple' => [
'label' => 'LLL:EXT:backend/Resources/Private/Language/locallang_pages_new.xlf:title',
'iconIdentifier' => 'apps-pagetree-drag-move-between',
'callbackAction' => 'pagesNewMultiple',
],
'mountAsTreeRoot' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.tempMountPoint',
'iconIdentifier' => 'actions-pagetree-mountroot',
'callbackAction' => 'mountAsTreeRoot',
],
'showInMenus' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:CM_showInMenus',
'iconIdentifier' => 'actions-view',
'callbackAction' => 'showInMenus',
],
'hideInMenus' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:CM_hideInMenus',
'iconIdentifier' => 'actions-ban',
'callbackAction' => 'hideInMenus',
],
],
],
'divider3' => [
'type' => 'divider',
],
'enable' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:enable',
'iconIdentifier' => 'actions-edit-unhide',
'callbackAction' => 'enableRecord',
],
'disable' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:disable',
'iconIdentifier' => 'actions-edit-hide',
'callbackAction' => 'disableRecord',
],
'delete' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.delete',
'iconIdentifier' => 'actions-edit-delete',
'callbackAction' => 'deleteRecord',
],
'history' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:CM_history',
'iconIdentifier' => 'actions-document-history-open',
'callbackAction' => 'openHistoryPopUp',
],
'clearCache' => [
'label' => 'core.cache:page.label',
'iconIdentifier' => 'actions-system-cache-clear',
'callbackAction' => 'clearCache',
],
];
protected bool $languageAccess = false;
/**
* Checks if the provider can add items to the menu
*/
public function canHandle(): bool
{
return $this->table === 'pages';
}
public function getPriority(): int
{
return 100;
}
protected function canRender(string $itemName, string $type): bool
{
if (in_array($type, ['divider', 'submenu'], true)) {
return true;
}
if (in_array($itemName, $this->disabledItems, true)) {
return false;
}
$canRender = false;
switch ($itemName) {
case 'view':
case 'qrcode':
$canRender = $this->canBeViewed();
break;
case 'edit':
$canRender = $this->canBeEdited();
break;
case 'new':
case 'pagesNewMultiple':
$canRender = $this->canBeCreated();
break;
case 'info':
$canRender = $this->canShowInfo();
break;
case 'enable':
$canRender = $this->canBeEnabled();
break;
case 'disable':
$canRender = $this->canBeDisabled();
break;
case 'showInMenus':
$canRender = $this->canBeToggled('nav_hide', 1);
break;
case 'hideInMenus':
$canRender = $this->canBeToggled('nav_hide', 0);
break;
case 'delete':
$canRender = $this->canBeDeleted();
break;
case 'history':
$canRender = $this->canShowHistory();
break;
case 'pagesSort':
$canRender = $this->canBeSorted();
break;
case 'mountAsTreeRoot':
$canRender = !$this->isRoot();
break;
case 'copy':
$canRender = $this->canBeCopied();
break;
case 'copyRelease':
$canRender = $this->isRecordInClipboard('copy');
break;
case 'cut':
$canRender = $this->canBeCut() && !$this->isRecordInClipboard('cut');
break;
case 'cutRelease':
$canRender = $this->isRecordInClipboard('cut');
break;
case 'pasteAfter':
$canRender = $this->canBePastedAfter();
break;
case 'pasteInto':
$canRender = $this->canBePastedInto();
break;
case 'clearCache':
$canRender = $this->canClearCache();
break;
}
return $canRender;
}
/**
* Saves calculated permissions for a page to speed things up
*/
protected function initPermissions(): void
{
$this->pagePermissions = new Permission($this->backendUser->calcPerms($this->record));
$this->languageAccess = $this->hasLanguageAccess();
}
/**
* Checks if the user may create pages below the given page
*/
protected function canBeCreated(): bool
{
if (!$this->backendUser->checkLanguageAccess(0)) {
return false;
}
if ($this->getLanguageField() !== ''
&& !in_array($this->record[$this->getLanguageField()] ?? false, [0, -1])
) {
return false;
}
if (!$this->backendUser->check('tables_modify', $this->table)) {
return false;
}
return $this->hasPagePermission(Permission::PAGE_NEW);
}
/**
* Checks if the user has editing rights
*/
protected function canBeEdited(): bool
{
if (!$this->languageAccess) {
return false;
}
if ($this->isRoot()) {
return false;
}
if ($this->getSchema()?->hasCapability(TcaSchemaCapability::AccessReadOnly)) {
return false;
}
if ($this->backendUser->isAdmin()) {
return true;
}
if ($this->getSchema()?->hasCapability(TcaSchemaCapability::AccessAdminOnly)) {
return false;
}
if (!$this->backendUser->check('tables_modify', $this->table)) {
return false;
}
return !$this->isRecordLocked() && $this->hasPagePermission(Permission::PAGE_EDIT);
}
/**
* Check if a page is locked
*/
protected function isRecordLocked(): bool
{
return (bool)$this->record[$this->getSchema()->getCapability(TcaSchemaCapability::EditLock)->getFieldName()];
}
/**
* Checks if the page is allowed to can be cut
*/
protected function canBeCut(): bool
{
if (!$this->languageAccess) {
return false;
}
if ($this->getLanguageField() !== ''
&& !in_array($this->record[$this->getLanguageField()] ?? false, [0, -1])
) {
return false;
}
if (!$this->backendUser->check('tables_modify', $this->table)) {
return false;
}
return !$this->isWebMount()
&& $this->canBeEdited()
&& !$this->isDeletePlaceholder();
}
/**
* Checks if the page is allowed to be copied
*/
protected function canBeCopied(): bool
{
if (!$this->languageAccess) {
return false;
}
if ($this->getLanguageField() !== ''
&& !in_array($this->record[$this->getLanguageField()] ?? false, [0, -1])
) {
return false;
}
if (!$this->backendUser->check('tables_select', $this->table)) {
return false;
}
return !$this->isRoot()
&& !$this->isWebMount()
&& !$this->isRecordInClipboard('copy')
&& $this->hasPagePermission(Permission::PAGE_SHOW)
&& !$this->isDeletePlaceholder();
}
/**
* Checks if something can be pasted into the node
*/
protected function canBePastedInto(): bool
{
if (!$this->languageAccess) {
return false;
}
$clipboardElementCount = count($this->clipboard->elFromTable($this->table));
return $clipboardElementCount
&& $this->canBeCreated()
&& !$this->isDeletePlaceholder();
}
/**
* Checks if something can be pasted after the node
*/
protected function canBePastedAfter(): bool
{
if (!$this->languageAccess) {
return false;
}
$clipboardElementCount = count($this->clipboard->elFromTable($this->table));
return $clipboardElementCount
&& $this->canBeCreated()
&& !$this->isDeletePlaceholder();
}
/**
* Check if sub pages of given page can be sorted
*/
protected function canBeSorted(): bool
{
if (!$this->languageAccess) {
return false;
}
return $this->backendUser->check('tables_modify', $this->table)
&& $this->hasPagePermission(Permission::CONTENT_EDIT)
&& !$this->isDeletePlaceholder()
&& $this->backendUser->workspace === 0;
}
/**
* Checks if the page is allowed to be removed
*/
protected function canBeDeleted(): bool
{
if (!$this->languageAccess) {
return false;
}
return !$this->isRoot()
&& !$this->isDeletePlaceholder()
&& !$this->isRecordLocked()
&& !$this->isDeletionDisabledInTS()
&& $this->hasPagePermission(Permission::PAGE_DELETE);
}
/**
* Checks if the page is allowed to be viewed in frontend
*/
protected function canBeViewed(): bool
{
return !$this->isRoot()
&& !$this->isDeleted()
&& $this->previewLinkCanBeBuild();
}
/**
* Checks if the page is allowed to show info
*/
protected function canShowInfo(): bool
{
return !$this->isRoot();
}
/**
* Checks if the user has clear cache rights
*/
protected function canClearCache(): bool
{
return !$this->isRoot()
&& ($this->backendUser->isAdmin() || ($this->backendUser->getTSConfig()['options.']['clearCache.']['pages'] ?? false));
}
/**
* Determines whether this node is deleted.
*/
protected function isDeleted(): bool
{
return !empty($this->record['deleted']) || $this->isDeletePlaceholder();
}
/**
* Returns true if current record is a root page
*/
protected function isRoot(): bool
{
return (int)$this->identifier === 0;
}
/**
* Returns true if current record is a web mount
*/
protected function isWebMount(): bool
{
return in_array($this->identifier, $this->backendUser->getWebmounts());
}
protected function getAdditionalAttributes(string $itemName): array
{
$attributes = [];
if ($itemName === 'view' || $itemName === 'qrcode') {
$attributes += $this->getViewAdditionalAttributes();
}
if ($itemName === 'enable' || $itemName === 'disable') {
$attributes += $this->getEnableDisableAdditionalAttributes();
}
if ($itemName === 'delete') {
$attributes += $this->getDeleteAdditionalAttributes();
}
if ($itemName === 'pasteInto') {
$attributes += $this->getPasteAdditionalAttributes('into');
}
if ($itemName === 'pasteAfter') {
$attributes += $this->getPasteAdditionalAttributes('after');
}
if ($itemName === 'pagesSort') {
$attributes += [
'data-pages-sort-url' => (string)$this->uriBuilder->buildUriFromRoute('pages_sort', ['id' => $this->record['uid'] ?? null]),
];
}
if ($itemName === 'pagesNewMultiple') {
$attributes += [
'data-pages-new-multiple-url' => (string)$this->uriBuilder->buildUriFromRoute('pages_new', ['id' => $this->record['uid'] ?? 0]),
];
}
if ($itemName === 'edit') {
$attributes = [
'data-pages-language-uid' => $this->record[$this->getLanguageField()] ?? null,
];
}
return $attributes;
}
protected function getPreviewPid(): int
{
return (int)($this->record[$this->getLanguageField()] ?? 0) === 0 ? (int)$this->record['uid'] : (int)$this->record['l10n_parent'];
}
/**
* Returns the view link
*/
protected function getViewLink(): string
{
return (string)PreviewUriBuilder::create($this->record)->buildUri();
}
/**
* Checks if user has access to this column, the doktype
* is not excluded and that it contains the given value.
*/
protected function canBeToggled(string $fieldName, int $value): bool
{
if (!$this->languageAccess || $this->isRoot()) {
return false;
}
$field = $this->getSchema()->getField($fieldName);
if ($field->supportsAccessControl()
&& !$this->isExcludedDoktype()
&& $this->backendUser->check('non_exclude_fields', $this->table . ':' . $fieldName)
&& $this->backendUser->check('tables_modify', $this->table)
) {
return (int)$this->record[$fieldName] === $value;
}
return false;
}
/**
* Returns true if a current user has access to the language of the record
*
* @see BackendUserAuthentication::checkLanguageAccess()
*/
protected function hasLanguageAccess(): bool
{
if ($this->backendUser->isAdmin()) {
return true;
}
if (($languageField = $this->getLanguageField()) !== '' && isset($this->record[$languageField])) {
return $this->backendUser->checkLanguageAccess((int)$this->record[$languageField]);
}
return true;
}
/**
* Returns true if the page doktype is excluded
*/
protected function isExcludedDoktype(): bool
{
$doktypeRegistry = GeneralUtility::makeInstance(PageDoktypeRegistry::class);
return !$doktypeRegistry->isPageTypeViewable((int)($this->record['doktype'] ?? 0));
}
}
@@ -0,0 +1,42 @@
<?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\ContextMenu\ItemProviders;
/**
* Interface for context menu items providers
*/
interface ProviderInterface
{
public function addItems(array $items): array;
/**
* Returns the priority of the provider. Higher priority value means provider is executed first
*/
public function getPriority(): int;
/**
* Checks if the provider can add items to the menu
*/
public function canHandle(): bool;
/**
* Initialize the current context.
* This method is called directly after fetching the provider from the container.
*/
public function setContext(string $table, string $identifier, string $context = ''): void;
}
@@ -0,0 +1,663 @@
<?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\ContextMenu\ItemProviders;
use TYPO3\CMS\Backend\Domain\Repository\Localization\LocalizationRepository;
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\JsConfirmation;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchema;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Versioning\VersionState;
/**
* Class responsible for providing click menu items for db records which don't have custom provider (as e.g. pages)
*/
class RecordProvider extends AbstractProvider
{
/**
* Database record
*
* @var array
*/
protected $record = [];
/**
* Database record of the page $this->record is placed on
*
* @var array
*/
protected $pageRecord = [];
/**
* Local cache for the result of BackendUserAuthentication::calcPerms()
*
* @var Permission
*/
protected $pagePermissions;
/**
* @var array
*/
protected $itemsConfiguration = [
'view' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.view',
'iconIdentifier' => 'actions-view',
'callbackAction' => 'viewRecord',
],
'edit' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.edit',
'iconIdentifier' => 'actions-open',
'callbackAction' => 'editRecord',
],
'new' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.new',
'iconIdentifier' => 'actions-plus',
'callbackAction' => 'newRecord',
],
'info' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.info',
'iconIdentifier' => 'actions-document-info',
'callbackAction' => 'openInfoPopUp',
],
'divider1' => [
'type' => 'divider',
],
'copy' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.copy',
'iconIdentifier' => 'actions-edit-copy',
'callbackAction' => 'copy',
],
'copyRelease' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.copy',
'iconIdentifier' => 'actions-edit-copy-release',
'callbackAction' => 'clipboardRelease',
],
'cut' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.cut',
'iconIdentifier' => 'actions-edit-cut',
'callbackAction' => 'cut',
],
'cutRelease' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.cutrelease',
'iconIdentifier' => 'actions-edit-cut-release',
'callbackAction' => 'clipboardRelease',
],
'pasteAfter' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.pasteafter',
'iconIdentifier' => 'actions-document-paste-after',
'callbackAction' => 'pasteAfter',
],
'divider2' => [
'type' => 'divider',
],
'more' => [
'type' => 'submenu',
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.more',
'iconIdentifier' => '',
'callbackAction' => 'openSubmenu',
'childItems' => [
'newWizard' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:CM_newWizard',
'iconIdentifier' => 'actions-plus',
'callbackAction' => 'newContentWizard',
],
],
],
'divider3' => [
'type' => 'divider',
],
'enable' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:enable',
'iconIdentifier' => 'actions-edit-unhide',
'callbackAction' => 'enableRecord',
],
'disable' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:disable',
'iconIdentifier' => 'actions-edit-hide',
'callbackAction' => 'disableRecord',
],
'delete' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.delete',
'iconIdentifier' => 'actions-edit-delete',
'callbackAction' => 'deleteRecord',
],
'history' => [
'label' => 'LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:CM_history',
'iconIdentifier' => 'actions-document-history-open',
'callbackAction' => 'openHistoryPopUp',
],
];
public function __construct(
protected readonly TcaSchemaFactory $tcaSchemaFactory,
protected readonly UriBuilder $uriBuilder,
protected readonly LocalizationRepository $localizationRepository,
) {
parent::__construct();
}
/**
* Whether this provider should kick in
*/
public function canHandle(): bool
{
if (in_array($this->table, ['sys_file', 'pages'], true)) {
return false;
}
return $this->tcaSchemaFactory->has($this->table);
}
/**
* Initialize db record
*/
protected function initialize()
{
parent::initialize();
$this->record = BackendUtility::getRecordWSOL($this->table, (int)$this->identifier);
$this->initPermissions();
}
/**
* Priority is set to lower then default value, in order to skip this provider if there is less generic provider available.
*/
public function getPriority(): int
{
return 60;
}
/**
* This provider works as a fallback if there is no provider dedicated for certain table, thus it's only kicking in when $items are empty.
*/
public function addItems(array $items): array
{
if (!empty($items)) {
return $items;
}
$this->initialize();
return $this->prepareItems($this->itemsConfiguration);
}
/**
* Whether a given item can be rendered (e.g. user has enough permissions)
*/
protected function canRender(string $itemName, string $type): bool
{
if (in_array($type, ['divider', 'submenu'], true)) {
return true;
}
if (in_array($itemName, $this->disabledItems, true)) {
return false;
}
$canRender = false;
switch ($itemName) {
case 'view':
$canRender = $this->canBeViewed();
break;
case 'edit':
$canRender = $this->canBeEdited();
break;
case 'new':
$canRender = $this->canBeNew();
break;
case 'newWizard':
$canRender = $this->canOpenNewCEWizard();
break;
case 'info':
$canRender = $this->canShowInfo();
break;
case 'enable':
$canRender = $this->canBeEnabled();
break;
case 'disable':
$canRender = $this->canBeDisabled();
break;
case 'delete':
$canRender = $this->canBeDeleted();
break;
case 'history':
$canRender = $this->canShowHistory();
break;
case 'copy':
$canRender = $this->canBeCopied();
break;
case 'copyRelease':
$canRender = $this->isRecordInClipboard('copy');
break;
case 'cut':
$canRender = $this->canBeCut();
break;
case 'cutRelease':
$canRender = $this->isRecordInClipboard('cut');
break;
case 'pasteAfter':
$canRender = $this->canBePastedAfter();
break;
}
return $canRender;
}
/**
* Saves calculated permissions for a page containing given record, to speed things up
*/
protected function initPermissions()
{
$this->pageRecord = BackendUtility::getRecord('pages', $this->record['pid']) ?? [];
$this->pagePermissions = new Permission($this->backendUser->calcPerms($this->pageRecord));
}
/**
* Returns true if a current user have access to given permission
*
* @see BackendUserAuthentication::doesUserHaveAccess()
*/
protected function hasPagePermission(int $permission): bool
{
return $this->backendUser->isAdmin() || $this->pagePermissions->isGranted($permission);
}
/**
* Additional attributes for JS
*/
protected function getAdditionalAttributes(string $itemName): array
{
$attributes = [];
if ($itemName === 'view') {
$attributes += $this->getViewAdditionalAttributes();
}
if ($itemName === 'enable' || $itemName === 'disable') {
$attributes += $this->getEnableDisableAdditionalAttributes();
}
if ($itemName === 'newWizard' && $this->table === 'tt_content') {
$urlParameters = [
'id' => $this->record['pid'],
'sys_language_uid' => $this->record[$this->getLanguageField()] ?? null,
'colPos' => $this->record['colPos'],
'uid_pid' => -$this->record['uid'],
];
$url = (string)$this->uriBuilder->buildUriFromRoute('new_content_element_wizard', $urlParameters);
$attributes += [
'data-new-wizard-url' => $url,
'data-title' => $this->languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang_layout.xlf:newContentElement'),
];
}
if ($itemName === 'delete') {
$attributes += $this->getDeleteAdditionalAttributes();
}
if ($itemName === 'pasteAfter') {
$attributes += $this->getPasteAdditionalAttributes('after');
}
return $attributes;
}
/**
* Additional attributes for the 'view' item
*/
protected function getViewAdditionalAttributes(): array
{
$attributes = [];
$viewLink = $this->getViewLink();
if ($viewLink) {
$attributes += [
'data-preview-url' => $viewLink,
];
}
return $attributes;
}
/**
* Additional attributes for the hide & unhide items
*/
protected function getEnableDisableAdditionalAttributes(): array
{
$hiddenFieldName = '';
if (($schema = $this->getSchema())?->hasCapability(TcaSchemaCapability::RestrictionDisabledField)) {
$hiddenFieldName = $schema->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getFieldName();
}
return [
'data-disable-field' => $hiddenFieldName,
];
}
/**
* Additional attributes for the pasteInto and pasteAfter items
*
* @param string $type "after" or "into"
*/
protected function getPasteAdditionalAttributes(string $type): array
{
$closeText = $this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:cancel');
$okText = $this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:ok');
$attributes = [];
if ($this->backendUser->jsConfirmation(JsConfirmation::COPY_MOVE_PASTE)) {
$selItem = $this->clipboard->getSelectedRecord();
$title = $this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:clip_paste');
$confirmMessage = sprintf(
$this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:mess.'
. ($this->clipboard->currentMode() === 'copy' ? 'copy' : 'move') . '_' . $type),
BackendUtility::cropToTitleLength($selItem['_RECORD_TITLE']),
BackendUtility::cropToTitleLength(BackendUtility::getRecordTitle($this->table, $this->record))
);
$attributes += [
'data-title' => $title,
'data-message' => $confirmMessage,
'data-button-close-text' => $closeText,
'data-button-ok-text' => $okText,
];
}
return $attributes;
}
/**
* Additional data for a "delete" action (confirmation modal title and message)
*/
protected function getDeleteAdditionalAttributes(): array
{
$closeText = $this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:cancel');
$okText = $this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf:delete');
$attributes = [];
if ($this->backendUser->jsConfirmation(JsConfirmation::DELETE)) {
$title = $this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:mess.delete.title');
$recordInfo = BackendUtility::cropToTitleLength(BackendUtility::getRecordTitle($this->table, $this->record));
if ($this->backendUser->shallDisplayDebugInformation()) {
$recordInfo .= ' [' . $this->table . ':' . $this->record['uid'] . ']';
}
$confirmMessage = sprintf(
$this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:mess.delete'),
trim($recordInfo)
);
$confirmMessage .= BackendUtility::referenceCount(
$this->table,
$this->record['uid'],
LF . $this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.referencesToRecord')
);
$translationCount = count($this->localizationRepository->getRecordTranslations($this->table, $this->record['uid']));
if ($translationCount > 0) {
$confirmMessage .= LF . sprintf(
$this->languageService->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.translationsOfRecord'),
$translationCount
);
}
$attributes += [
'data-title' => $title,
'data-message' => $confirmMessage,
'data-button-close-text' => $closeText,
'data-button-ok-text' => $okText,
];
}
return $attributes;
}
/**
* Returns id of the Page used for preview
*/
protected function getPreviewPid(): int
{
return (int)$this->record['pid'];
}
/**
* Returns the view link
*/
protected function getViewLink(): string
{
return (string)PreviewUriBuilder::createForRecordPreview(
$this->table,
$this->record,
$this->pageRecord['uid'] ?? 0
)->buildUri();
}
/**
* Checks if the page is allowed to show info
*/
protected function canShowInfo(): bool
{
return true;
}
/**
* Checks if the page is allowed to show info
*/
protected function canShowHistory(): bool
{
$userTsConfig = $this->backendUser->getTSConfig();
return (bool)trim($userTsConfig['options.']['showHistory.'][$this->table] ?? $userTsConfig['options.']['showHistory'] ?? '1');
}
/**
* Checks if the record can be previewed in frontend
*/
protected function canBeViewed(): bool
{
return $this->previewLinkCanBeBuild();
}
/**
* Whether a record can be edited
*/
protected function canBeEdited(): bool
{
if ($this->getSchema()?->hasCapability(TcaSchemaCapability::AccessReadOnly)) {
return false;
}
if ($this->backendUser->isAdmin()) {
return true;
}
if ($this->getSchema()?->hasCapability(TcaSchemaCapability::AccessAdminOnly)) {
return false;
}
$access = !$this->isRecordLocked()
&& $this->backendUser->check('tables_modify', $this->table)
&& $this->hasPagePermission(Permission::CONTENT_EDIT)
&& $this->backendUser->checkRecordEditAccess($this->table, $this->record)->isAllowed;
return $access;
}
/**
* Whether a record can be created
*/
protected function canBeNew(): bool
{
return $this->canBeEdited() && !$this->isRecordATranslation();
}
/**
* Checks if disableDelete flag is set in TSConfig for the current table
*/
protected function isDeletionDisabledInTS(): bool
{
return (bool)trim(
$this->backendUser->getTSConfig()['options.']['disableDelete.'][$this->table]
?? $this->backendUser->getTSConfig()['options.']['disableDelete']
?? ''
);
}
/**
* Checks if the user has the right to delete the record
*/
protected function canBeDeleted(): bool
{
return !$this->isDeletionDisabledInTS()
&& !$this->isRecordCurrentBackendUser()
&& $this->canBeEdited();
}
/**
* Returns true if current record can be unhidden/enabled
*/
protected function canBeEnabled(): bool
{
return $this->hasDisableColumnWithValue(1) && $this->canBeEdited();
}
/**
* Returns true if current record can be hidden
*/
protected function canBeDisabled(): bool
{
return $this->hasDisableColumnWithValue(0)
&& !$this->isRecordCurrentBackendUser()
&& $this->canBeEdited();
}
/**
* Returns true new content element wizard can be shown
*/
protected function canOpenNewCEWizard(): bool
{
return $this->table === 'tt_content' && $this->canBeEdited() && !$this->isRecordATranslation();
}
protected function canBeCopied(): bool
{
return !$this->isRecordInClipboard('copy')
&& !$this->isRecordATranslation();
}
protected function canBeCut(): bool
{
return !$this->isRecordInClipboard('cut')
&& $this->canBeEdited()
&& !$this->isRecordATranslation();
}
/**
* Paste after is only shown for records from the same table (comparing record in clipboard and record clicked)
*/
protected function canBePastedAfter(): bool
{
$clipboardElementCount = count($this->clipboard->elFromTable($this->table));
return $clipboardElementCount
&& $this->backendUser->check('tables_modify', $this->table)
&& $this->hasPagePermission(Permission::CONTENT_EDIT);
}
/**
* Checks if table have "disable" column (e.g. "hidden"), if user has access to this column
* and if it contains given value
*/
protected function hasDisableColumnWithValue(int $value): bool
{
if (!$this->getSchema()?->hasCapability(TcaSchemaCapability::RestrictionDisabledField)) {
return false;
}
$hiddenField = $this->getSchema()->getCapability(TcaSchemaCapability::RestrictionDisabledField)->getField();
if (!$hiddenField->supportsAccessControl() || $this->backendUser->check('non_exclude_fields', $this->table . ':' . $hiddenField->getName())) {
return (int)($this->record[$hiddenField->getName()] ?? 0) === $value;
}
return false;
}
/**
* Record is locked if page is locked or page is not locked but record is
*/
protected function isRecordLocked(): bool
{
if (($pageSchema = $this->tcaSchemaFactory->get('pages'))->hasCapability(TcaSchemaCapability::EditLock)
&& ($this->pageRecord[$pageSchema->getCapability(TcaSchemaCapability::EditLock)->getFieldName()] ?? false)
) {
return true;
}
if (!$this->getSchema()?->hasCapability(TcaSchemaCapability::EditLock)) {
return false;
}
return (bool)$this->record[$this->getSchema()->getCapability(TcaSchemaCapability::EditLock)->getFieldName()];
}
/**
* Returns true is a current record is a delete placeholder
*/
protected function isDeletePlaceholder(): bool
{
return VersionState::tryFrom($this->record['t3ver_state'] ?? 0) === VersionState::DELETE_PLACEHOLDER;
}
/**
* Checks if current record is in the "normal" pad of the clipboard
*
* @param string $mode "copy", "cut" or '' for any mode
*/
protected function isRecordInClipboard(string $mode = ''): bool
{
$isSelected = '';
if ($this->clipboard->current === 'normal' && isset($this->record['uid'])) {
$isSelected = $this->clipboard->isSelected($this->table, $this->record['uid']);
}
return $mode === '' ? !empty($isSelected) : $isSelected === $mode;
}
/**
* Returns true is a record ia a translation
*/
protected function isRecordATranslation(): bool
{
if (!$this->getSchema()?->isLanguageAware()) {
return false;
}
return (int)$this->record[$this->getSchema()->getCapability(TcaSchemaCapability::Language)->getTranslationOriginPointerField()->getName()] !== 0;
}
/**
* Return true in case the current record is the current backend user
*/
protected function isRecordCurrentBackendUser(): bool
{
return $this->table === 'be_users' && (int)($this->record['uid'] ?? 0) === $this->backendUser->getUserId();
}
protected function getIdentifier(): string
{
return $this->record['uid'];
}
/**
* Returns true if a view link can be built for the record
*/
protected function previewLinkCanBeBuild(): bool
{
return $this->getViewLink() !== '';
}
/**
* Returns the configured language field
*/
protected function getLanguageField(): string
{
if (!$this->getSchema()?->isLanguageAware()) {
return '';
}
return $this->getSchema()->getCapability(TcaSchemaCapability::Language)->getLanguageField()->getName();
}
protected function getSchema(): ?TcaSchema
{
if ($this->tcaSchemaFactory->has($this->table)) {
return $this->tcaSchemaFactory->get($this->table);
}
return null;
}
}
@@ -0,0 +1,138 @@
<?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\ContextMenu\ItemProviders;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Site\SiteFinder;
/**
* Context menu item provider adding "Site Settings" and "Edit Site Configuration" for pages that are site roots
*/
final class SiteSettingsProvider extends AbstractProvider
{
private const array ITEMS_CONFIGURATION = [
'editSiteConfiguration' => [
'label' => 'backend.siteconfiguration:contextMenu.editSiteConfiguration',
'iconIdentifier' => 'actions-window',
'callbackAction' => 'openSiteConfiguration',
],
'editSiteSettings' => [
'label' => 'backend.siteconfiguration:contextMenu.editSiteSettings',
'iconIdentifier' => 'actions-window-cog',
'callbackAction' => 'openSiteSettings',
],
];
public function __construct(
private readonly SiteFinder $siteFinder,
private readonly UriBuilder $uriBuilder,
) {
parent::__construct();
}
public function canHandle(): bool
{
// Site configuration module requires admin access
return $this->table === 'pages' && $this->backendUser->isAdmin();
}
public function getPriority(): int
{
return 60;
}
public function addItems(array $items): array
{
$this->initDisabledItems();
// Add site items after "edit" item
$localItems = $this->prepareItems(self::ITEMS_CONFIGURATION);
$position = array_search('edit', array_keys($items), true);
if ($position !== false) {
$items = [
...array_slice($items, 0, $position + 1, true),
...$localItems,
...array_slice($items, $position + 1, null, true),
];
} else {
$items = [...$items, ...$localItems];
}
return $items;
}
protected function canRender(string $itemName, string $type): bool
{
if (in_array($itemName, $this->disabledItems, true)) {
return false;
}
if ($itemName === 'editSiteSettings' || $itemName === 'editSiteConfiguration') {
return $this->canOpenSiteSettings();
}
return true;
}
protected function getAdditionalAttributes(string $itemName): array
{
$pageId = (int)$this->identifier;
try {
$site = $this->siteFinder->getSiteByRootPageId($pageId);
if ($itemName === 'editSiteSettings') {
return [
'data-site-settings-url' => (string)$this->uriBuilder->buildUriFromRoute(
'site_configuration.editSettings',
['site' => $site->getIdentifier()]
),
];
}
if ($itemName === 'editSiteConfiguration') {
return [
'data-site-configuration-url' => (string)$this->uriBuilder->buildUriFromRoute(
'site_configuration.edit',
['site' => $site->getIdentifier()]
),
];
}
} catch (SiteNotFoundException) {
return [];
}
return [];
}
private function canOpenSiteSettings(): bool
{
// Check if this page is a site root
$pageId = (int)$this->identifier;
if ($pageId <= 0) {
return false;
}
try {
$this->siteFinder->getSiteByRootPageId($pageId);
return true;
} catch (SiteNotFoundException) {
return false;
}
}
}
+95
View File
@@ -0,0 +1,95 @@
<?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\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Template\Enum\ModuleLayout;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Information\Typo3Information;
use TYPO3\CMS\Core\Information\Typo3Version;
use TYPO3\CMS\Core\Package\PackageManager;
/**
* Module 'about' shows some standard information for TYPO3 CMS:
* About-text, version number, available modules and so on.
*
* @internal This is a specific Backend Controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class AboutController
{
public function __construct(
protected Typo3Version $version,
protected Typo3Information $typo3Information,
protected ModuleProvider $moduleProvider,
protected EventDispatcherInterface $eventDispatcher,
protected PackageManager $packageManager,
protected ModuleTemplateFactory $moduleTemplateFactory,
) {}
/**
* Main action: Show standard information
*/
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$event = new Event\ModifyGenericBackendMessagesEvent();
$event = $this->eventDispatcher->dispatch($event);
$view = $this->moduleTemplateFactory->create($request);
$view->setLayout(ModuleLayout::NORMAL);
$view->assignMultiple([
'typo3Info' => $this->typo3Information,
'typo3Version' => $this->version,
'donationUrl' => $this->typo3Information::URL_DONATE,
'trademarkUrl' => $this->typo3Information::URL_TRADEMARK,
'loadedExtensions' => $this->getLoadedExtensions(),
'messages' => $event->getMessages(),
'modules' => $this->moduleProvider->getModules($this->getBackendUser()),
]);
return $view->renderResponse('About/Index');
}
/**
* Fetches a list of all active (loaded) extensions in the current system
*/
protected function getLoadedExtensions(): array
{
$extensions = [];
foreach ($this->packageManager->getActivePackages() as $package) {
// Skip system extensions
if ($package->getPackageMetaData()->isFrameworkType()) {
continue;
}
$extensions[] = [
'key' => $package->getPackageKey(),
'title' => $package->getPackageMetaData()->getTitle(),
'authors' => $package->getValueFromComposerManifest('authors'),
];
}
return $extensions;
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,80 @@
<?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\Controller;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Page\JavaScriptItems;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
/**
* Abstract class for a couple of FormEngine controllers triggered by
* ajax calls. The class containers some helpers to for instance prepare
* the form render result for json output.
*
* @internal Marked as internal for now, methods in this class may change any time.
*/
abstract readonly class AbstractFormEngineAjaxController
{
protected function addJavaScriptModulesToJavaScriptItems(array $modules, JavaScriptItems $items): void
{
foreach ($modules as $module) {
if (!$module instanceof JavaScriptModuleInstruction) {
throw new \LogicException(
sprintf(
'Module must be a %s, type "%s" given',
JavaScriptModuleInstruction::class,
gettype($module)
),
1663851377
);
}
$items->addJavaScriptModuleInstruction($module);
}
}
/**
* Resolve a CSS file position, possibly prefixed with 'EXT:'
*
* @param string $stylesheetFile Given file, possibly prefixed with EXT:
* @return string URL to file
*/
protected function getRelativePathToStylesheetFile(string $stylesheetFile): string
{
return (string)PathUtility::getSystemResourceUri($stylesheetFile);
}
/**
* Parse a language file and get a label/value array from it.
*
* @param string $file EXT:path/to/file
* @return array Label/value array
*/
protected function getLabelsFromLocalizationFile(string $file): array
{
$languageService = $this->getLanguageService() ?? GeneralUtility::makeInstance(LanguageServiceFactory::class)->create('en');
return $languageService->getLabelsFromResource($file);
}
protected function getLanguageService(): ?LanguageService
{
return $GLOBALS['LANG'] ?? null;
}
}
@@ -0,0 +1,556 @@
<?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\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Controller\Event\ModifyAllowedItemsEvent;
use TYPO3\CMS\Backend\Controller\Event\ModifyLinkHandlersEvent;
use TYPO3\CMS\Backend\LinkHandler\LinkHandlerInterface;
use TYPO3\CMS\Backend\LinkHandler\LinkHandlerVariableProviderInterface;
use TYPO3\CMS\Backend\LinkHandler\LinkHandlerViewProviderInterface;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\PageRendererBackendSetupTrait;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Service\DependencyOrderingService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\View\ViewInterface;
/**
* Script class for the Link Browser window.
*
* @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
*/
abstract class AbstractLinkBrowserController
{
use PageRendererBackendSetupTrait;
/**
* @var array<string, array>
*/
protected array $linkHandlers = [];
/**
* All parts of the current link.
* Comprised of url information and additional link parameters.
*
* @var array<string, mixed>
*/
protected array $currentLinkParts = [];
/**
* Link handler responsible for the current active link
*/
protected ?LinkHandlerInterface $currentLinkHandler = null;
/**
* The ID of the currently active link handler
*/
protected string $currentLinkHandlerId;
/**
* Link handler to be displayed
*/
protected ?LinkHandlerInterface $displayedLinkHandler = null;
/**
* The ID of the displayed link handler
* This is read from the 'act' GET parameter
*/
protected string $displayedLinkHandlerId = '';
/**
* List of available link attribute fields
*
* @var string[]
*/
protected array $linkAttributeFields = [];
/**
* Values of the link attributes
*
* @var string[]
*/
protected array $linkAttributeValues = [];
protected array $parameters;
protected DependencyOrderingService $dependencyOrderingService;
protected PageRenderer $pageRenderer;
protected UriBuilder $uriBuilder;
protected ExtensionConfiguration $extensionConfiguration;
protected BackendViewFactory $backendViewFactory;
protected EventDispatcherInterface $eventDispatcher;
public function injectDependencyOrderingService(DependencyOrderingService $dependencyOrderingService): void
{
$this->dependencyOrderingService = $dependencyOrderingService;
}
public function injectPageRenderer(PageRenderer $pageRenderer): void
{
$this->pageRenderer = $pageRenderer;
}
public function injectUriBuilder(UriBuilder $uriBuilder): void
{
$this->uriBuilder = $uriBuilder;
}
public function injectExtensionConfiguration(ExtensionConfiguration $extensionConfiguration): void
{
$this->extensionConfiguration = $extensionConfiguration;
}
public function injectBackendViewFactory(BackendViewFactory $backendViewFactory): void
{
$this->backendViewFactory = $backendViewFactory;
}
public function injectEventDispatcher(EventDispatcherInterface $eventDispatcher): void
{
$this->eventDispatcher = $eventDispatcher;
}
abstract public function getConfiguration(): array;
abstract protected function initDocumentTemplate(): void;
abstract protected function getCurrentPageId(): int;
/**
* Injects the request object for the current request or subrequest
* As this controller goes only through the main() method, it is rather simple for now
*
* @param ServerRequestInterface $request the current request
* @return ResponseInterface the response with the content
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$this->setUpBasicPageRendererForBackend($this->pageRenderer, $this->extensionConfiguration, $request, $this->getLanguageService());
$this->pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_misc.xlf');
$this->pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_core.xlf');
$this->initVariables($request);
$this->loadLinkHandlers();
$this->initCurrentUrl();
$menuData = $this->buildMenuArray($request);
if ($this->displayedLinkHandler instanceof LinkHandlerViewProviderInterface) {
$view = $this->displayedLinkHandler->createView($this->backendViewFactory, $request);
} else {
$view = $this->backendViewFactory->create($request, ['typo3/cms-backend']);
}
if ($this->displayedLinkHandler instanceof LinkHandlerVariableProviderInterface) {
$this->displayedLinkHandler->initializeVariables($request);
}
$renderLinkAttributeFields = $this->renderLinkAttributeFields($view);
if (!empty($this->currentLinkParts)) {
$this->renderCurrentUrl($view);
}
if (method_exists($this->displayedLinkHandler, 'setView')) {
$this->displayedLinkHandler->setView($view);
}
$view->assignMultiple([
'initialNavigationWidth' => $this->getBackendUser()->uc['selector']['navigation']['width'] ?? 250,
'menuItems' => $menuData,
'linkAttributes' => $renderLinkAttributeFields,
'contentOnly' => $request->getQueryParams()['contentOnly'] ?? false,
]);
$content = $this->displayedLinkHandler->render($request);
if (empty($content)) {
// @todo: b/w compat layer for link handler that don't render full view but return empty
// string instead. This case is unfortunate and should be removed if it gives
// headaches at some point. If so, above method_exists($this->displayedLinkHandler, 'setView')
// should be removed and setView() method should be made mandatory, or the entire
// construct should be refactored a bit.
$content = $view->render();
}
$this->initDocumentTemplate();
$this->pageRenderer->setTitle($this->getLanguageService()->sL(
'LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:linkBrowser'
));
if ($request->getQueryParams()['contentOnly'] ?? false) {
return new HtmlResponse($content);
}
$this->pageRenderer->setBodyContent('<body ' . GeneralUtility::implodeAttributes($this->getBodyTagAttributes(), true, true) . '>' . $content);
return $this->pageRenderer->renderResponse($request);
}
/**
* @return array{act: string, P: array} Array of parameters which have to be added to URLs
*/
public function getUrlParameters(?array $overrides = null): array
{
return [
'act' => $overrides['act'] ?? $this->displayedLinkHandlerId,
'P' => $overrides['P'] ?? $this->parameters,
];
}
public function getParameters(): array
{
return $this->parameters;
}
protected function initVariables(ServerRequestInterface $request): void
{
$queryParams = $request->getQueryParams();
$this->displayedLinkHandlerId = $queryParams['act'] ?? '';
$this->parameters = $queryParams['P'] ?? [];
$this->linkAttributeValues = $queryParams['linkAttributes'] ?? [];
$pageTsConfig = BackendUtility::getPagesTSconfig((int)($this->parameters['pid'] ?? 0));
$handlerId = $this->displayedLinkHandlerId ?: 'page';
if (empty($this->linkAttributeValues['target'])) {
$defaultTarget = $pageTsConfig['TCEMAIN.']['linkHandler.'][$handlerId . '.']['target.']['default']
?? $pageTsConfig['TCEMAIN.']['linkHandler.']['properties.']['target.']['default']
?? '';
if (!empty($defaultTarget)) {
$this->linkAttributeValues['target'] = $defaultTarget;
}
}
if (empty($this->linkAttributeValues['class'])) {
$defaultCssClass = $pageTsConfig['TCEMAIN.']['linkHandler.'][$handlerId . '.']['cssClass.']['default']
?? $pageTsConfig['TCEMAIN.']['linkHandler.']['properties.']['cssClass.']['default']
?? '';
if (!empty($defaultCssClass)) {
$this->linkAttributeValues['class'] = $defaultCssClass;
}
}
}
/**
* @throws \UnexpectedValueException
*/
protected function loadLinkHandlers(): void
{
$linkHandlers = $this->getLinkHandlers();
if (empty($linkHandlers)) {
throw new \UnexpectedValueException('No link handlers are configured. Check page TSconfig TCEMAIN.linkHandler.', 1442787911);
}
$lang = $this->getLanguageService();
foreach ($linkHandlers as $identifier => $configuration) {
$identifier = rtrim($identifier, '.');
if ($identifier === 'properties') {
continue;
}
if (empty($configuration['handler'])) {
throw new \UnexpectedValueException(sprintf('Missing handler for link handler "%1$s", check page TSconfig TCEMAIN.linkHandler.%1$s.handler', $identifier), 1494579849);
}
/** @var LinkHandlerInterface $handler */
$handler = GeneralUtility::makeInstance($configuration['handler']);
$handler->initialize(
$this,
$identifier,
$configuration['configuration.'] ?? []
);
$label = !empty($configuration['label']) ? $lang->sL($configuration['label']) : '';
$label = $label ?: $lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:error.linkHandlerTitleMissing');
$this->linkHandlers[$identifier] = [
'handlerInstance' => $handler,
'label' => $label,
'displayBefore' => isset($configuration['displayBefore']) ? GeneralUtility::trimExplode(',', $configuration['displayBefore']) : [],
'displayAfter' => isset($configuration['displayAfter']) ? GeneralUtility::trimExplode(',', $configuration['displayAfter']) : [],
'scanBefore' => isset($configuration['scanBefore']) ? GeneralUtility::trimExplode(',', $configuration['scanBefore']) : [],
'scanAfter' => isset($configuration['scanAfter']) ? GeneralUtility::trimExplode(',', $configuration['scanAfter']) : [],
'addParams' => $configuration['addParams'] ?? '',
];
}
}
/**
* Reads the configured link handlers from page TSconfig
*
* @return array<string, array>
*/
protected function getLinkHandlers(): array
{
$linkHandlers = (array)(BackendUtility::getPagesTSconfig($this->getCurrentPageId())['TCEMAIN.']['linkHandler.'] ?? []);
return $this->eventDispatcher
->dispatch(new ModifyLinkHandlersEvent($linkHandlers, $this->currentLinkParts))
->getLinkHandlers();
}
/**
* Initialize $this->currentLinkParts and $this->currentLinkHandler
*/
protected function initCurrentUrl(): void
{
if (empty($this->currentLinkParts)) {
return;
}
$orderedHandlers = $this->dependencyOrderingService->orderByDependencies($this->linkHandlers, 'scanBefore', 'scanAfter');
// find responsible handler for current link
foreach ($orderedHandlers as $key => $configuration) {
/** @var LinkHandlerInterface $handler */
$handler = $configuration['handlerInstance'];
if ($handler->canHandleLink($this->currentLinkParts)) {
$this->currentLinkHandler = $handler;
$this->currentLinkHandlerId = $key;
break;
}
}
// reset the link if we have no handler for it
if (!$this->currentLinkHandler) {
$this->currentLinkParts = [];
}
// overwrite any preexisting
foreach ($this->currentLinkParts as $key => $part) {
if ($key !== 'url') {
$this->linkAttributeValues[$key] = $part;
}
}
}
/**
* Add the currently set Link URL to the view
*/
protected function renderCurrentUrl(ViewInterface $view): void
{
$view->assign('currentLink', $this->currentLinkHandler->formatCurrentUrl());
}
/**
* Returns an array definition of the top menu
*
* @return array[]
*/
protected function buildMenuArray(ServerRequestInterface $request): array
{
$allowedItems = $this->getAllowedItems();
if ($this->displayedLinkHandlerId && !in_array($this->displayedLinkHandlerId, $allowedItems, true)) {
$this->displayedLinkHandlerId = '';
}
$allowedHandlers = array_flip($allowedItems);
$menuDef = [];
foreach ($this->linkHandlers as $identifier => $configuration) {
if (!isset($allowedHandlers[$identifier])) {
continue;
}
/** @var LinkHandlerInterface $handlerInstance */
$handlerInstance = $configuration['handlerInstance'];
$isActive = $this->displayedLinkHandlerId === $identifier || (!$this->displayedLinkHandlerId && $handlerInstance === $this->currentLinkHandler);
if ($isActive) {
$this->displayedLinkHandler = $handlerInstance;
if (!$this->displayedLinkHandlerId) {
$this->displayedLinkHandlerId = $this->currentLinkHandlerId;
}
}
$menuDef[$identifier] = [
'isActive' => $isActive,
'label' => $configuration['label'],
'url' => $this->uriBuilder->buildUriFromRequest($request, $this->getUrlParameters(['act' => $identifier])),
'addParams' => $configuration['addParams'] ?? '',
'before' => $configuration['displayBefore'],
'after' => $configuration['displayAfter'],
];
}
$menuDef = $this->dependencyOrderingService->orderByDependencies($menuDef);
// if there is no active tab
if (!$this->displayedLinkHandler) {
// empty the current link
$this->currentLinkParts = [];
$this->currentLinkHandler = null;
// select first tab
$this->displayedLinkHandlerId = (string)array_key_first($menuDef);
$this->displayedLinkHandler = $this->linkHandlers[$this->displayedLinkHandlerId]['handlerInstance'];
$menuDef[$this->displayedLinkHandlerId]['isActive'] = true;
}
return $menuDef;
}
/**
* @return string[]
*/
protected function getAllowedItems(): array
{
$allowedItems = $this->eventDispatcher
->dispatch(new ModifyAllowedItemsEvent(array_keys($this->linkHandlers), $this->currentLinkParts))
->getAllowedItems();
if (isset($this->parameters['params']['allowedTypes'])) {
$allowedItems = array_intersect($allowedItems, GeneralUtility::trimExplode(',', $this->parameters['params']['allowedTypes'], true));
} elseif (isset($this->parameters['params']['blindLinkOptions'])) {
// @todo Deprecate this option
$allowedItems = array_diff($allowedItems, GeneralUtility::trimExplode(',', $this->parameters['params']['blindLinkOptions'], true));
}
return $allowedItems;
}
/**
* @return string[]
*/
protected function getAllowedLinkAttributes(): array
{
$allowedLinkAttributes = $this->displayedLinkHandler->getLinkAttributes();
if (isset($this->parameters['params']['allowedOptions'])) {
$allowedLinkAttributes = array_intersect($allowedLinkAttributes, GeneralUtility::trimExplode(',', $this->parameters['params']['allowedOptions'], true));
} elseif (isset($this->parameters['params']['blindLinkFields'])) {
// @todo Deprecate this option
$allowedLinkAttributes = array_diff($allowedLinkAttributes, GeneralUtility::trimExplode(',', $this->parameters['params']['blindLinkFields'], true));
}
return $allowedLinkAttributes;
}
/**
* Renders the link attributes for the selected link handler
*/
protected function renderLinkAttributeFields(ViewInterface $view): string
{
$fieldRenderingDefinitions = $this->getLinkAttributeFieldDefinitions();
$fieldRenderingDefinitions = $this->displayedLinkHandler->modifyLinkAttributes($fieldRenderingDefinitions);
$this->linkAttributeFields = $this->getAllowedLinkAttributes();
$content = '';
foreach ($this->linkAttributeFields as $attribute) {
$content .= $fieldRenderingDefinitions[$attribute] ?? '';
}
$view->assign('allowedLinkAttributes', array_combine($this->linkAttributeFields, $this->linkAttributeFields));
// add update button if appropriate
if (!empty($this->currentLinkParts) && $this->displayedLinkHandler === $this->currentLinkHandler && $this->currentLinkHandler->isUpdateSupported()) {
$view->assign('showUpdateParametersButton', true);
}
return $content;
}
/**
* Create an array of link attribute field rendering definitions
*
* @return string[]
*/
protected function getLinkAttributeFieldDefinitions(): array
{
$lang = $this->getLanguageService();
$fieldRenderingDefinitions = [];
$fieldRenderingDefinitions['target'] = '
<!-- Selecting target for link: -->
<div class="element-browser-form-group">
<label for="ltarget" class="form-label">
' . htmlspecialchars($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:target')) . '
</label>
<typo3-backend-combobox>
<input id="ltarget" type="text" name="ltarget" class="form-control" value="' . htmlspecialchars($this->linkAttributeValues['target'] ?? '') . '" />
<typo3-backend-combobox-choice value="_top" icon="actions-window">' . $lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:top') . '</typo3-backend-combobox-choice>
<typo3-backend-combobox-choice value="_blank" icon="actions-window-open">' . $lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:newWindow') . '</typo3-backend-combobox-choice>
</typo3-backend-combobox>
</div>';
$fieldRenderingDefinitions['title'] = '
<!-- Selecting title for link: -->
<div class="element-browser-form-group">
<label for="ltitle" class="form-label">' . htmlspecialchars($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:title')) . '</label>
<input id="ltitle" type="text" name="ltitle" class="form-control"
value="' . htmlspecialchars($this->linkAttributeValues['title'] ?? '') . '" />
</div>';
$fieldRenderingDefinitions['class'] = '
<!-- Selecting class for link: -->
<div class="element-browser-form-group">
<label for="lclass" class="form-label">
' . htmlspecialchars($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:class')) . '
</label>
<input id="lclass" type="text" name="lclass" class="form-control"
value="' . htmlspecialchars($this->linkAttributeValues['class'] ?? '') . '" />
</div>';
$fieldRenderingDefinitions['params'] = '
<!-- Selecting params for link: -->
<div class="element-browser-form-group">
<label for="lparams" class="form-label">' . htmlspecialchars($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:params')) . '</label>
<input id="lparams" type="text" name="lparams" class="form-control"
value="' . htmlspecialchars($this->linkAttributeValues['params'] ?? '') . '" />
</div>';
$fieldRenderingDefinitions['rel'] = '
<!-- Selecting rel for link: -->
<div class="element-browser-form-group">
<label for="lrel" class="form-label">' . htmlspecialchars($lang->sL('backend.browse_links:linkRelationship')) . '</label>
<input id="lrel" type="text" name="lrel" class="form-control"
value="' . htmlspecialchars($this->linkAttributeValues['rel'] ?? '') . '" />
</div>';
$fieldRenderingDefinitions['download'] = '
<!-- Selecting download for link: -->
<div class="element-browser-form-group">
<typo3-backend-link-browser-download
value="' . htmlspecialchars($this->linkAttributeValues['download'] ?? '') . '"
label-download="' . htmlspecialchars($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:download')) . '"
label-filename="' . htmlspecialchars($lang->sL('LLL:EXT:backend/Resources/Private/Language/locallang_browse_links.xlf:download.customFilename')) . '"
></typo3-backend-link-browser-download>
</div>';
return $fieldRenderingDefinitions;
}
/**
* @return string[] Array of body-tag attributes
*/
protected function getBodyTagAttributes(): array
{
$attributes = $this->displayedLinkHandler->getBodyTagAttributes();
return array_merge(
$attributes,
[
'data-linkbrowser-parameters' => json_encode($this->parameters) ?: '',
'data-linkbrowser-attribute-fields' => json_encode(array_values($this->linkAttributeFields)) ?: '',
]
);
}
protected function getDisplayedLinkHandlerId(): string
{
return $this->displayedLinkHandlerId;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,115 @@
<?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\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderManifestInterface;
use TYPO3\CMS\Core\Authentication\Mfa\MfaProviderRegistry;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Abstract class for mfa controllers (configuration and authentication)
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
abstract class AbstractMfaController
{
protected MfaProviderRegistry $mfaProviderRegistry;
protected array $mfaTsConfig;
protected bool $mfaRequired;
protected array $allowedProviders;
protected array $allowedActions = [];
public function injectMfaProviderRegistry(MfaProviderRegistry $mfaProviderRegistry): void
{
$this->mfaProviderRegistry = $mfaProviderRegistry;
}
/**
* Main action for handling the request and returning the response
*/
abstract public function handleRequest(ServerRequestInterface $request): ResponseInterface;
protected function isActionAllowed(string $action): bool
{
return in_array($action, $this->allowedActions, true);
}
protected function isProviderAllowed(string $identifier): bool
{
return isset($this->allowedProviders[$identifier]);
}
protected function isValidIdentifier(string $identifier): bool
{
return $identifier !== ''
&& $this->isProviderAllowed($identifier)
&& $this->mfaProviderRegistry->hasProvider($identifier);
}
/**
* Initialize MFA configuration based on TSconfig and global configuration
*/
protected function initializeMfaConfiguration(): void
{
$backendUser = $this->getBackendUser();
$this->mfaTsConfig = $backendUser->getTSConfig()['auth.']['mfa.'] ?? [];
$this->mfaRequired = $backendUser->isMfaSetupRequired();
// Set up allowed providers based on user TSconfig and user groupData
$this->allowedProviders = array_filter($this->mfaProviderRegistry->getProviders(), function (string $identifier) use ($backendUser): bool {
return $backendUser->check('mfa_providers', $identifier)
&& !GeneralUtility::inList(($this->mfaTsConfig['disableProviders'] ?? ''), $identifier);
}, ARRAY_FILTER_USE_KEY);
}
/**
* Get the recommended provider
*/
protected function getRecommendedProvider(): ?MfaProviderManifestInterface
{
$recommendedProviderIdentifier = (string)($this->mfaTsConfig['recommendedProvider'] ?? '');
// Check if valid and allowed to be default provider, which is obviously a prerequisite
if (!$this->isValidIdentifier($recommendedProviderIdentifier)
|| !$this->mfaProviderRegistry->getProvider($recommendedProviderIdentifier)->isDefaultProviderAllowed()
) {
// If the provider, defined in user TSconfig is not valid or is not set, check the globally defined
$recommendedProviderIdentifier = (string)($GLOBALS['TYPO3_CONF_VARS']['BE']['recommendedMfaProvider'] ?? '');
if (!$this->isValidIdentifier($recommendedProviderIdentifier)
|| !$this->mfaProviderRegistry->getProvider($recommendedProviderIdentifier)->isDefaultProviderAllowed()
) {
// If also not valid or not set, return
return null;
}
}
return $this->mfaProviderRegistry->getProvider($recommendedProviderIdentifier);
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+158
View File
@@ -0,0 +1,158 @@
<?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\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Authentication\BackendLocker;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Authentication\LoginType;
use TYPO3\CMS\Core\FormProtection\BackendFormProtection;
use TYPO3\CMS\Core\FormProtection\FormProtectionFactory;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Session\UserSessionManager;
/**
* This is the ajax handler for backend login after timeout.
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class AjaxLoginController
{
public function __construct(
protected FormProtectionFactory $formProtectionFactory,
protected BackendLocker $lockService,
) {}
/**
* Handles the actual login process, more specifically it defines the response.
* The login details were sent in as part of the ajax request and automatically logged in
* the user inside the BackendUserAuthenticator middleware. If that was successful, we have
* a BE user and reset the timer and hide the login window.
* If it was unsuccessful, we display that and show the login box again.
*/
public function loginAction(ServerRequestInterface $request): ResponseInterface
{
if ($this->isAuthorizedBackendSession()) {
$result = ['success' => true];
if ($this->hasLoginBeenProcessed($request)) {
/** @var BackendFormProtection $formProtection */
$formProtection = $this->formProtectionFactory->createFromRequest($request);
$formProtection->setSessionTokenFromRegistry();
$formProtection->persistSessionToken();
}
} else {
$result = ['success' => false];
}
return new JsonResponse(['login' => $result]);
}
/**
* Logs out the current BE user
*/
public function logoutAction(ServerRequestInterface $request): ResponseInterface
{
$backendUser = $this->getBackendUser();
$backendUser->logoff();
return new JsonResponse([
'logout' => [
'success' => !isset($backendUser->user['uid']),
],
]);
}
public function preflightAction(ServerRequestInterface $request): ResponseInterface
{
$headers = $request->getHeaders();
return new JsonResponse([
'capabilities' => [
'cookie' => !empty($request->getCookieParams()),
// using legacy `Referer` (sic!) header name
'referrer' => array_filter($headers['referer'] ?? []) !== [],
],
]);
}
/**
* Handles the actual session refresh, more specifically it defines the response.
* The session refresh has been performed inside the BackendUserAuthenticator middleware.
* If that was successful, we have a BE user and report that information as response.
*/
public function refreshAction(ServerRequestInterface $request): ResponseInterface
{
$backendUser = $this->getBackendUser();
return new JsonResponse([
'refresh' => [
'success' => isset($backendUser->user['uid']),
],
]);
}
/**
* Checks if the user session is expired yet
*/
public function isTimedOutAction(ServerRequestInterface $request): ResponseInterface
{
$session = [
'timed_out' => false,
'will_time_out' => false,
'locked' => false,
];
$backendUser = $this->getBackendUser();
if ($this->lockService->isLocked()) {
$session['locked'] = true;
} elseif (!isset($backendUser->user['uid'])) {
$session['timed_out'] = true;
} else {
$sessionManager = UserSessionManager::create('BE');
// If 120 seconds from now is later than the session timeout, we need to show the refresh dialog.
// 120 is somewhat arbitrary to allow for a little room during the countdown and load times, etc.
$session['will_time_out'] = $sessionManager->willExpire($backendUser->getSession(), 120);
}
return new JsonResponse(['login' => $session]);
}
/**
* Checks if a user is logged in and the session is active.
*
* @return bool
*/
protected function isAuthorizedBackendSession()
{
$backendUser = $this->getBackendUser();
if ($backendUser === null) {
return false;
}
return isset($backendUser->user['uid']);
}
/**
* Check whether the user was already authorized or not
*/
protected function hasLoginBeenProcessed(ServerRequestInterface $request): bool
{
$loginFormData = $this->getBackendUser()->getLoginFormData($request);
return LoginType::tryFrom($loginFormData['status'] ?? '') === LoginType::LOGIN && !empty($loginFormData['uname']) && !empty($loginFormData['uident']);
}
protected function getBackendUser(): ?BackendUserAuthentication
{
return $GLOBALS['BE_USER'] ?? null;
}
}
+389
View File
@@ -0,0 +1,389 @@
<?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\Controller;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Backend\Bookmark\BookmarkService;
use TYPO3\CMS\Backend\Controller\Event\AfterBackendPageRenderEvent;
use TYPO3\CMS\Backend\Controller\Event\BeforeBackendPageRenderEvent;
use TYPO3\CMS\Backend\Date\DateConfigurationFactory;
use TYPO3\CMS\Backend\Module\ModuleInterface;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Routing\Exception\RouteNotFoundException;
use TYPO3\CMS\Backend\Routing\Router;
use TYPO3\CMS\Backend\Routing\RouteRedirect;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Sidebar\Sidebar;
use TYPO3\CMS\Backend\Sidebar\SidebarComponentContext;
use TYPO3\CMS\Backend\Sidebar\SidebarFactory;
use TYPO3\CMS\Backend\Template\PageRendererBackendSetupTrait;
use TYPO3\CMS\Backend\Toolbar\RequestAwareToolbarItemInterface;
use TYPO3\CMS\Backend\Toolbar\ToolbarItemInterface;
use TYPO3\CMS\Backend\Toolbar\ToolbarItemsRegistry;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Information\Typo3Version;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Routing\BackendEntryPointResolver;
use TYPO3\CMS\Core\Type\ContextualFeedbackSeverity;
use TYPO3\CMS\Core\Type\File\ImageInfo;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\MathUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
use TYPO3\CMS\Core\View\ViewInterface;
/**
* Class for rendering the TYPO3 backend.
* This is the backend outer main frame with topbar and module menu.
*/
#[AsController]
readonly class BackendController
{
use PageRendererBackendSetupTrait;
public function __construct(
protected Typo3Version $typo3Version,
protected UriBuilder $uriBuilder,
protected PageRenderer $pageRenderer,
protected ModuleProvider $moduleProvider,
protected ToolbarItemsRegistry $toolbarItemsRegistry,
protected SidebarFactory $sidebarFactory,
protected ExtensionConfiguration $extensionConfiguration,
protected BackendViewFactory $viewFactory,
protected EventDispatcherInterface $eventDispatcher,
protected FlashMessageService $flashMessageService,
protected BackendEntryPointResolver $backendEntryPointResolver,
protected BookmarkService $bookmarkService,
protected DateConfigurationFactory $dateConfigurationFactory,
) {}
/**
* Main function generating the BE scaffolding.
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$backendUser = $this->getBackendUser();
$pageRenderer = $this->pageRenderer;
// apply nonce hint for elements that are shown in a modal
$pageRenderer->setApplyNonceHint(true);
$this->setUpBasicPageRendererForBackend($pageRenderer, $this->extensionConfiguration, $request, $this->getLanguageService());
$javaScriptRenderer = $pageRenderer->getJavaScriptRenderer();
$javaScriptRenderer->addGlobalAssignment(['window' => [
'name' => 'typo3-backend', // reset window name to a standardized value
'opener' => null, // remove any previously set opener value
]]);
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/login-refresh.js')
->invoke('initialize', [
'intervalTime' => MathUtility::forceIntegerInRange((int)$GLOBALS['TYPO3_CONF_VARS']['BE']['sessionTimeout'] - 60, 60),
'requestTokenUrl' => (string)$this->uriBuilder->buildUriFromRoute('login_request_token'),
'loginFramesetUrl' => (string)$this->uriBuilder->buildUriFromRoute('login_frameset'),
'logoutUrl' => (string)$this->uriBuilder->buildUriFromRoute('logout'),
])
);
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/broadcast-service.js')->invoke('listen')
);
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/hotkeys/negotiator.js')
);
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/hotkeys.js')
);
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/user-settings-manager.js')
);
// load the storage API and fill the UC into the PersistentStorage, so no additional AJAX call is needed
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/storage/persistent.js')
->invoke('load', $backendUser->uc)
);
// Initialize bookmark store with server data if bookmarks are enabled
if ($this->bookmarkService->isEnabled()) {
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/bookmark/bookmark-store.js')
->invoke('initialize', $this->bookmarkService->getBookmarks(), $this->bookmarkService->getGroups())
);
}
$javaScriptRenderer->addGlobalAssignment([
'TYPO3' => [
'configuration' => [
'username' => htmlspecialchars($backendUser->user['username']),
'showRefreshLoginPopup' => (bool)($GLOBALS['TYPO3_CONF_VARS']['BE']['showRefreshLoginPopup'] ?? false),
],
],
]);
$javaScriptRenderer->includeAllImports();
// @todo: This loads a ton of labels into JS. This should be reviewed what is really needed.
// This could happen when the localization API gets an overhaul.
$pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_core.xlf');
$pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_misc.xlf');
$pageRenderer->addInlineLanguageLabelFile('EXT:backend/Resources/Private/Language/locallang_layout.xlf');
$pageRenderer->addInlineLanguageLabelFile('EXT:backend/Resources/Private/Language/locallang_settingseditor.xlf');
$pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_mod_web_list.xlf');
// @todo: We can not put this into the template since PageRendererViewHelper does not deal with namespace in addInlineSettings argument
$pageRenderer->addInlineSetting('ShowItem', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('show_item'));
$pageRenderer->addInlineSetting('Resource', 'thumbnailUrl', (string)$this->uriBuilder->buildUriFromRoute('resource_request_thumbnail'));
$pageRenderer->addInlineSetting('RecordHistory', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('record_history'));
$pageRenderer->addInlineSetting('NewRecord', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('db_new'));
$pageRenderer->addInlineSetting('FormEngine', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('record_edit'));
$pageRenderer->addInlineSetting('RecordCommit', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('tce_db'));
$pageRenderer->addInlineSetting('FileCommit', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('tce_file'));
$pageRenderer->addInlineSetting('Clipboard', 'moduleUrl', (string)$this->uriBuilder->buildUriFromRoute('clipboard_process'));
$pageRenderer->addInlineSetting('Wizards', 'elementBrowserUrl', (string)$this->uriBuilder->buildUriFromRoute('wizard_element_browser'));
// Needed for FormEngine manipulation (date picker) and DateTime components
$pageRenderer->addInlineSetting(null, 'DateConfiguration', $this->dateConfigurationFactory->getConfiguration('javascript'));
$typo3Version = 'TYPO3 CMS ' . $this->typo3Version->getVersion();
$title = $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] ? $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename'] . ' [' . $typo3Version . ']' : $typo3Version;
$pageRenderer->setTitle($title);
$sidebarContext = new SidebarComponentContext($request, $backendUser);
$sidebar = $this->sidebarFactory->create($sidebarContext);
$view = $this->viewFactory->create($request);
$this->assignTopbarDetailsToView($request, $view, $sidebar);
$startupModule = $this->getStartupModule($request);
$noModuleAccess = $startupModule[0] === null && empty($this->moduleProvider->getModulesForModuleMenu($backendUser));
$view->assignMultiple([
'startupModule' => $startupModule,
'noModuleAccess' => $noModuleAccess,
'workspaceAccessDenied' => $noModuleAccess && $backendUser->workspace === -99,
'entryPoint' => $this->backendEntryPointResolver->getPathFromRequest($request),
'stateTracker' => (string)$this->uriBuilder->buildUriFromRoute('state-tracker'),
'sitename' => $title,
'sitenameFirstInBackendTitle' => ($backendUser->uc['backendTitleFormat'] ?? '') === 'sitenameFirst',
'sidebar' => $sidebar->render(),
]);
$this->eventDispatcher->dispatch(new BeforeBackendPageRenderEvent($view, $javaScriptRenderer, $pageRenderer));
$content = $view->render('Backend/Main');
$content = $this->eventDispatcher->dispatch(new AfterBackendPageRenderEvent($content, $view))->getContent();
$pageRenderer->addBodyContent('<body>' . $content);
return $pageRenderer->renderResponse($request);
}
/**
* Returns the main module menu as json encoded HTML string. Used when
* "update signals" request a menu reload, e.g. when an extension is loaded
* that brings new main modules.
*/
public function getModuleMenu(ServerRequestInterface $request): ResponseInterface
{
$sidebarContext = new SidebarComponentContext($request, $this->getBackendUser());
$component = $this->sidebarFactory->create($sidebarContext)->getComponentByIdentifier('module-menu');
return new JsonResponse(['menu' => $component?->getResult($sidebarContext)->html]);
}
/**
* Returns the toolbar as json encoded HTML string. Used when
* "update signals" request a toolbar reload, e.g. when an extension is loaded.
*/
public function getTopbar(ServerRequestInterface $request): ResponseInterface
{
$sidebar = $this->sidebarFactory->create(new SidebarComponentContext($request, $this->getBackendUser()));
$view = $this->viewFactory->create($request);
$this->assignTopbarDetailsToView($request, $view, $sidebar);
return new JsonResponse(['topbar' => $view->render('Backend/Topbar')]);
}
/**
* Renders the topbar, containing the backend logo, sitename etc.
*/
protected function assignTopbarDetailsToView(ServerRequestInterface $request, ViewInterface $view, Sidebar $sidebar): void
{
// Extension Configuration to find the TYPO3 logo in the left corner
$extConf = $this->extensionConfiguration->get('backend');
$logoPath = '';
$logoUrl = '';
$logoWidth = 22;
$logoHeight = 22;
if (!empty($extConf['backendLogo'])) {
$configuredLogo = ltrim($extConf['backendLogo'], '/');
$customBackendLogo = GeneralUtility::getFileAbsFileName($configuredLogo);
if ($customBackendLogo !== '' && file_exists($customBackendLogo)) {
$logoPath = $customBackendLogo;
$logoUrl = (string)PathUtility::getSystemResourceUri($configuredLogo, $request);
// set width/height for custom logo
$imageInfo = GeneralUtility::makeInstance(ImageInfo::class, $logoPath);
$logoWidth = $imageInfo->getWidth() ?: $logoWidth;
$logoHeight = $imageInfo->getHeight() ?: $logoHeight;
// High-resolution?
if (str_contains($logoPath, '@2x.')) {
$logoWidth /= 2;
$logoHeight /= 2;
}
}
}
// if no custom logo was set or the path is invalid, use the original one
if ($logoPath === '') {
$logoUrl = (string)PathUtility::getSystemResourceUri('EXT:backend/Resources/Public/Images/typo3_logo_orange.svg', $request);
}
$view->assign('sidebar', $sidebar);
$view->assign('logoUrl', $logoUrl);
$view->assign('logoWidth', $logoWidth);
$view->assign('logoHeight', $logoHeight);
$view->assign('applicationVersion', $this->typo3Version->getVersion());
$view->assign('siteName', $GLOBALS['TYPO3_CONF_VARS']['SYS']['sitename']);
$view->assign('toolbarItems', $this->getToolbarItems($request));
$view->assign('isImpersonated', $this->getBackendUser()->getOriginalUserIdWhenInSwitchUserMode() !== null);
}
/**
* @return ToolbarItemInterface[]
*/
protected function getToolbarItems(ServerRequestInterface $request): array
{
return array_map(static function (ToolbarItemInterface $toolbarItem) use ($request): ToolbarItemInterface {
if ($toolbarItem instanceof RequestAwareToolbarItemInterface) {
$toolbarItem->setRequest($request);
}
return $toolbarItem;
}, array_filter(
$this->toolbarItemsRegistry->getToolbarItems(),
static fn(ToolbarItemInterface $toolbarItem): bool => $toolbarItem->checkAccess()
));
}
/**
* Sets the startup module from either "redirect" GET parameters or user configuration.
*
* @return array{?string, ?string}
*/
protected function getStartupModule(ServerRequestInterface $request): array
{
$startModule = null;
$startModuleIdentifier = null;
$inaccessibleRedirectModule = null;
$moduleParameters = [];
try {
$redirect = RouteRedirect::createFromRequest($request);
if ($redirect !== null && $request->getMethod() === 'GET') {
// Only redirect to existing non-ajax routes with no restriction to a specific method
$router = GeneralUtility::makeInstance(Router::class);
$redirect->resolve($router);
$module = $router->getRoute($redirect->getName())?->getOption('module');
if ($module instanceof ModuleInterface === false
|| $this->moduleProvider->accessGranted($module->getIdentifier(), $this->getBackendUser())
) {
// Only add start module from request in case user has access or it's a no module route,
// e.g. to FormEngine where permissions are checked by the corresponding component.
// Access might temporarily be blocked. e.g. due to being in a workspace.
$startModuleIdentifier = $redirect->getName();
$moduleParameters = $redirect->getParameters();
} elseif ($this->moduleProvider->isModuleRegistered($module->getIdentifier())) {
// A redirect is set, however, the user is not allowed to access the module.
// Store the requested module to later inform the user about the forced redirect.
$inaccessibleRedirectModule = $this->moduleProvider->getModule($module->getIdentifier());
}
}
} finally {
// No valid redirect, check for the start module
if (!$startModuleIdentifier) {
$backendUser = $this->getBackendUser();
// start module on first login, will be removed once used the first time
if (isset($backendUser->uc['startModuleOnFirstLogin'])) {
$startModuleIdentifier = $backendUser->uc['startModuleOnFirstLogin'];
unset($backendUser->uc['startModuleOnFirstLogin']);
$backendUser->writeUC();
} elseif (isset($backendUser->uc['startModule']) && $this->moduleProvider->accessGranted($backendUser->uc['startModule'], $backendUser)) {
$startModuleIdentifier = $backendUser->uc['startModule'];
} elseif ($firstAccessibleModule = $this->moduleProvider->getFirstAccessibleModule($backendUser)) {
$startModuleIdentifier = $firstAccessibleModule->getIdentifier();
}
// check if the start module has additional parameters, so a redirect to a specific
// action is possible
if (is_string($startModuleIdentifier) && str_contains($startModuleIdentifier, '->')) {
[$startModuleIdentifier, $startModuleParameters] = explode('->', $startModuleIdentifier, 2);
// if no GET parameters are set, check if there are parameters given from the UC
if (!$moduleParameters && $startModuleParameters) {
$moduleParameters = $startModuleParameters;
}
}
}
}
if ($startModuleIdentifier) {
if ($this->moduleProvider->isModuleRegistered($startModuleIdentifier)) {
// startModuleIdentifier may be an alias, resolve original module
$startModule = $this->moduleProvider->getModule($startModuleIdentifier, $this->getBackendUser());
$startModuleIdentifier = $startModule?->getIdentifier();
}
if (is_array($moduleParameters)) {
$parameters = $moduleParameters;
} else {
$parameters = [];
parse_str($moduleParameters, $parameters);
}
try {
$deepLink = $this->uriBuilder->buildUriFromRoute($startModuleIdentifier, $parameters);
if ($startModule !== null && $inaccessibleRedirectModule !== null) {
$this->enqueueRedirectMessage($inaccessibleRedirectModule, $startModule);
}
return [$startModuleIdentifier, (string)$deepLink];
} catch (RouteNotFoundException $e) {
// It might be, that the user does not have access to the
// $startModule, e.g. for modules with workspace restrictions.
}
}
return [null, null];
}
protected function enqueueRedirectMessage(ModuleInterface $requestedModule, ModuleInterface $redirectedModule): void
{
$languageService = $this->getLanguageService();
$this->flashMessageService
->getMessageQueueByIdentifier(FlashMessageQueue::NOTIFICATION_QUEUE)
->enqueue(
new FlashMessage(
sprintf(
$languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:module.noAccess.message'),
$languageService->sL($redirectedModule->getTitle()),
$languageService->sL($requestedModule->getTitle())
),
$languageService->sL('LLL:EXT:backend/Resources/Private/Language/locallang.xlf:module.noAccess.title'),
ContextualFeedbackSeverity::INFO,
true
)
);
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+319
View File
@@ -0,0 +1,319 @@
<?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\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Backend\Bookmark\BookmarkService;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
/**
* Controller for bookmark processing.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class BookmarkController
{
public function __construct(
protected BookmarkService $bookmarkService,
) {}
public function listAction(ServerRequestInterface $request): ResponseInterface
{
return new JsonResponse([
'success' => true,
'bookmarks' => $this->bookmarkService->getBookmarks(),
'groups' => $this->bookmarkService->getGroups(),
]);
}
public function createAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$routeIdentifier = $parsedBody['routeIdentifier'] ?? '';
$arguments = $parsedBody['arguments'] ?? '';
if ($routeIdentifier === '') {
return $this->errorResponse(
'core.bookmarks:error.missingRoute.message',
400
);
}
if ($this->bookmarkService->hasBookmark($routeIdentifier, $arguments)) {
return $this->errorResponse(
'core.bookmarks:error.createFailed.message'
);
}
$bookmarkName = $parsedBody['displayName'] ?? '';
$bookmarkId = $this->bookmarkService->createBookmark($routeIdentifier, $arguments, $bookmarkName);
if ($bookmarkId === false) {
return $this->errorResponse(
'core.bookmarks:error.createFailed.message',
500
);
}
$bookmark = $this->bookmarkService->getBookmark($bookmarkId);
return new JsonResponse([
'success' => true,
'bookmark' => $bookmark,
], 201);
}
public function updateAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$bookmarkId = (int)($parsedBody['bookmarkId'] ?? 0);
$bookmarkTitle = trim($parsedBody['bookmarkTitle'] ?? '');
// Group ID can be int (system group, including negative for global) or string (user-created UUID)
$bookmarkGroupId = $parsedBody['bookmarkGroup'] ?? 0;
if (is_numeric($bookmarkGroupId)) {
$bookmarkGroupId = (int)$bookmarkGroupId;
}
if ($bookmarkId === 0) {
return $this->errorResponse(
'core.bookmarks:error.missingBookmarkId.message',
400
);
}
$result = $this->bookmarkService->updateBookmark($bookmarkId, $bookmarkTitle, $bookmarkGroupId);
if ($result['success']) {
$bookmark = $this->bookmarkService->getBookmark($bookmarkId);
if ($bookmark !== null) {
$result['bookmark'] = $bookmark;
}
}
return new JsonResponse($result);
}
public function deleteAction(ServerRequestInterface $request): ResponseInterface
{
$bookmarkId = (int)($request->getParsedBody()['bookmarkId'] ?? 0);
if ($bookmarkId === 0) {
return $this->errorResponse(
'core.bookmarks:error.missingBookmarkId.message',
400
);
}
$result = $this->bookmarkService->deleteBookmark($bookmarkId);
return new JsonResponse($result);
}
public function reorderAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$bookmarkIds = $parsedBody['bookmarkIds'] ?? [];
if (!is_array($bookmarkIds) || $bookmarkIds === []) {
return $this->errorResponse(
'core.bookmarks:error.missingBookmarkIds.message',
400
);
}
$success = $this->bookmarkService->reorderBookmarks(array_map('intval', $bookmarkIds));
return new JsonResponse([
'success' => $success,
'bookmarks' => $this->bookmarkService->getBookmarks(),
]);
}
public function deleteMultipleAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$bookmarkIds = $parsedBody['bookmarkIds'] ?? [];
if (!is_array($bookmarkIds) || $bookmarkIds === []) {
return $this->errorResponse(
'core.bookmarks:error.missingBookmarkIds.message',
400
);
}
$success = $this->bookmarkService->deleteBookmarks(array_map('intval', $bookmarkIds));
return new JsonResponse(['success' => $success]);
}
public function moveAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$bookmarkIds = $parsedBody['bookmarkIds'] ?? [];
// Group ID can be int (system group, including negative for global) or string (user-created UUID)
$groupId = $parsedBody['groupId'] ?? 0;
if (is_numeric($groupId)) {
$groupId = (int)$groupId;
}
if (!is_array($bookmarkIds) || $bookmarkIds === []) {
return $this->errorResponse(
'core.bookmarks:error.missingBookmarkIds.message',
400
);
}
$success = $this->bookmarkService->moveBookmarks(array_map('intval', $bookmarkIds), $groupId);
return new JsonResponse(['success' => $success]);
}
public function createGroupAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$label = trim($parsedBody['label'] ?? '');
if ($label === '') {
return $this->errorResponse(
'core.bookmarks:error.missingLabel.message',
400
);
}
$group = $this->bookmarkService->createGroup($label);
if ($group === null) {
return $this->errorResponse(
'core.bookmarks:error.groupCreateFailed.message',
500
);
}
return new JsonResponse([
'success' => true,
'group' => $group,
], 201);
}
public function updateGroupAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$uuid = trim($parsedBody['uuid'] ?? '');
$label = trim($parsedBody['label'] ?? '');
if ($uuid === '') {
return $this->errorResponse(
'core.bookmarks:error.missingGroupId.message',
400
);
}
if ($label === '') {
return $this->errorResponse(
'core.bookmarks:error.missingLabel.message',
400
);
}
$success = $this->bookmarkService->updateGroup($uuid, $label);
if (!$success) {
return $this->errorResponse(
'core.bookmarks:error.groupUpdateFailed.message',
500
);
}
return new JsonResponse([
'success' => true,
'groups' => $this->bookmarkService->getGroups(),
]);
}
public function deleteGroupAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$uuid = trim($parsedBody['uuid'] ?? '');
if ($uuid === '') {
return $this->errorResponse(
'core.bookmarks:error.missingGroupId.message',
400
);
}
$success = $this->bookmarkService->deleteGroup($uuid);
if (!$success) {
return $this->errorResponse(
'core.bookmarks:error.groupDeleteFailed.message',
500
);
}
return new JsonResponse([
'success' => true,
'groups' => $this->bookmarkService->getGroups(),
]);
}
public function reorderGroupsAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$uuids = $parsedBody['uuids'] ?? [];
if (!is_array($uuids) || $uuids === []) {
return $this->errorResponse(
'core.bookmarks:error.missingGroupIds.message',
400
);
}
$success = $this->bookmarkService->reorderGroups($uuids);
if (!$success) {
return $this->errorResponse(
'core.bookmarks:error.groupReorderFailed.message',
500
);
}
return new JsonResponse([
'success' => true,
'groups' => $this->bookmarkService->getGroups(),
]);
}
private function errorResponse(string $labelKey, int $statusCode = 200): JsonResponse
{
$languageService = $this->getLanguageService();
return new JsonResponse([
'success' => false,
'error' => $languageService->sL($labelKey) ?: $labelKey,
], $statusCode);
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,98 @@
<?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\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Handles clearing caches from the clear cache toolbar item and the records module.
*
* @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
*/
readonly class ClearCacheController
{
public function flushCacheGroupPagesAction(ServerRequestInterface $request): ResponseInterface
{
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([], []);
$dataHandler->clear_cacheCmd('pages');
$languageService = $this->getLanguageService();
return new JsonResponse([
'success' => true,
'title' => $languageService->sL('core.cache:notification.group.pages.success.title'),
'message' => $languageService->sL('core.cache:notification.group.pages.success.message'),
]);
}
public function flushCacheGroupAllAction(ServerRequestInterface $request): ResponseInterface
{
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([], []);
$dataHandler->clear_cacheCmd('all');
$languageService = $this->getLanguageService();
return new JsonResponse([
'success' => true,
'title' => $languageService->sL('core.cache:notification.group.all.success.title'),
'message' => $languageService->sL('core.cache:notification.group.all.success.message'),
]);
}
public function flushCachePageAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$pageUid = (int)($parsedBody['id'] ?? 0);
$languageService = $this->getLanguageService();
$permissionClause = $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW);
$pageRow = BackendUtility::readPageAccess($pageUid, $permissionClause);
if ($pageUid !== 0 && $this->getBackendUser()->doesUserHaveAccess($pageRow, Permission::PAGE_SHOW)) {
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->start([], []);
$dataHandler->clear_cacheCmd($pageUid);
return new JsonResponse([
'success' => true,
'title' => $languageService->sL('core.cache:notification.page.success.title'),
'message' => sprintf($languageService->sL('core.cache:notification.page.success.message'), BackendUtility::getRecordTitle('pages', $pageRow)),
]);
}
return new JsonResponse([
'success' => false,
'title' => $languageService->sL('core.cache:notification.page.error.title'),
'message' => $languageService->sL('core.cache:notification.page.error.message'),
]);
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+113
View File
@@ -0,0 +1,113 @@
<?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\Controller;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\StreamFactoryInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Clipboard\Clipboard;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Controller which behaves as endpoint for clipboard requests, dispatched from
* either the clipboard panel web component or any of the corresponding modules.
*
* @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
*/
#[AsController]
class ClipboardController
{
private const array ALLOWED_ACTIONS = ['getClipboardData'];
protected ResponseFactoryInterface $responseFactory;
protected StreamFactoryInterface $streamFactory;
protected Clipboard $clipboard;
public function __construct(ResponseFactoryInterface $responseFactory, StreamFactoryInterface $streamFactory)
{
$this->responseFactory = $responseFactory;
$this->streamFactory = $streamFactory;
$this->clipboard = GeneralUtility::makeInstance(Clipboard::class);
}
/**
* Process incoming clipboard request
*/
public function processRequest(ServerRequestInterface $request): ResponseInterface
{
$this->clipboard->initializeClipboard($request);
$CB = (array)($request->getParsedBody()['CB'] ?? []);
if ($CB !== []) {
// Execute commands.
$this->clipboard->setCmd($CB);
}
// Clean up pad
$this->clipboard->cleanCurrent();
// Save the clipboard content
$this->clipboard->endClipboard();
$action = (string)($request->getQueryParams()['action'] ?? '');
if (in_array($action, self::ALLOWED_ACTIONS, true)) {
return $this->{$action . 'Action'}($request);
}
// Default response in case no dedicated action is requested.
// This is usually done if only internal clipboard state is changed.
return $this->createResponse(['success' => true, 'data' => []]);
}
protected function getClipboardDataAction(ServerRequestInterface $request): ResponseInterface
{
$clipboardData = $this->clipboard->getClipboardData($request->getParsedBody()['table'] ?? '');
// Add labels for the panel
$lang = $this->getLanguageService();
$clipboardLabels = [
'clipboard' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.clipboard'),
'copyElements' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:copyElements'),
'moveElements' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_misc.xlf:moveElements'),
'copy' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.copy'),
'cut' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.cut'),
'info' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:cm.info'),
'removeAll' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:buttons.removeAll'),
'removeItem' => $lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.removeItem'),
];
return $this->createResponse([
'success' => $clipboardData !== [],
'data' => array_merge($clipboardData, ['labels' => $clipboardLabels]),
]);
}
protected function createResponse(array $data): ResponseInterface
{
return $this->responseFactory->createResponse()
->withHeader('Content-Type', 'application/json; charset=utf-8')
->withBody($this->streamFactory->createStream(json_encode($data)));
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,129 @@
<?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\Controller\CodeEditor;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
use TYPO3\CMS\Core\TypoScript\IncludeTree\SysTemplateRepository;
use TYPO3\CMS\Core\TypoScript\IncludeTree\SysTemplateTreeBuilder;
use TYPO3\CMS\Core\TypoScript\IncludeTree\Traverser\IncludeTreeTraverser;
use TYPO3\CMS\Core\TypoScript\IncludeTree\Visitor\IncludeTreeAstBuilderVisitor;
use TYPO3\CMS\Core\TypoScript\Tokenizer\LossyTokenizer;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\RootlineUtility;
/**
* Code completion for code editor
*
* @internal This is a specific Backend Controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class CodeCompletionController
{
public function __construct(
private SysTemplateRepository $sysTemplateRepository,
private SysTemplateTreeBuilder $treeBuilder,
private LossyTokenizer $lossyTokenizer,
private IncludeTreeTraverser $treeTraverser,
) {}
/**
* Loads all templates up to a given page id (walking the rootline) and
* cleans parts that are not required for the code editor code-completion.
*/
public function loadCompletions(ServerRequestInterface $request): ResponseInterface
{
// Check whether access is granted (only admin have access to sys_template records):
if (!$GLOBALS['BE_USER']->isAdmin()) {
return new HtmlResponse($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_codeeditor.xlf:noPermission'), 500);
}
$pageId = (int)($request->getParsedBody()['pageId'] ?? $request->getQueryParams()['pageId']);
// Check whether there is a pageId given:
if (!$pageId) {
return new HtmlResponse($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_codeeditor.xlf:pageIDInteger'), 500);
}
// Fetch the templates
return new JsonResponse($this->getMergedTemplates($pageId, $request));
}
/**
* Gets merged templates by walking the rootline to a given page id.
* This is loaded once via ajax when a code editor in typoscript mode is fired.
* JS then knows the object types and can auto-complete on CTRL+space.
*
* @return array Setup part of merged template records
*/
protected function getMergedTemplates(int $pageId, ServerRequestInterface $request): array
{
$rootLine = GeneralUtility::makeInstance(RootlineUtility::class, $pageId)->get();
$sysTemplateRows = $this->sysTemplateRepository->getSysTemplateRowsByRootline($rootLine, $request);
/** @var SiteInterface|null $site */
$site = $request->getAttribute('site');
$setupIncludeTree = $this->treeBuilder->getTreeBySysTemplateRowsAndSite('setup', $sysTemplateRows, $this->lossyTokenizer, $site);
$setupAstBuilderVisitor = GeneralUtility::makeInstance(IncludeTreeAstBuilderVisitor::class);
$this->treeTraverser->traverse($setupIncludeTree, [$setupAstBuilderVisitor]);
$setupAst = $setupAstBuilderVisitor->getAst();
return $this->treeWalkCleanup($setupAst->toArray());
}
/**
* Walks through a tree of TypoScript configuration and prepares it for JS.
*/
private function treeWalkCleanup(array $treeBranch): array
{
$cleanedTreeBranch = [];
foreach ($treeBranch as $key => $value) {
$key = is_int($key) ? (string)$key : $key;
//type definition or value-assignment
if (substr($key, -1) !== '.') {
if ($value != '') {
if (mb_strlen($value) > 20) {
$value = mb_substr($value, 0, 20);
}
if (!isset($cleanedTreeBranch[$key])) {
$cleanedTreeBranch[$key] = [];
}
$cleanedTreeBranch[$key]['v'] = $value;
}
} else {
// subtree (definition of properties)
$subBranch = $this->treeWalkCleanup($value);
if ($subBranch) {
if (substr($key, -1) === '.') {
$key = rtrim($key, '.');
}
if (!isset($cleanedTreeBranch[$key])) {
$cleanedTreeBranch[$key] = [];
}
$cleanedTreeBranch[$key]['c'] = $subBranch;
}
}
}
return $cleanedTreeBranch;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -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\Backend\Controller\CodeEditor;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Loads TSref information from a XML file and responds to an AJAX call.
* @internal This is a specific Backend Controller implementation and is not considered part of the Public TYPO3 API.
*/
class TypoScriptReferenceController
{
/**
* Load TypoScript reference
*/
public function loadReference(ServerRequestInterface $request): ResponseInterface
{
// Load the TSref XML information
$xmlDoc = new \DOMDocument('1.0', 'utf-8');
$xmlDoc->loadXML(file_get_contents(GeneralUtility::getFileAbsFileName('EXT:backend/Resources/Private/tsref.xml')));
return new JsonResponse($this->getTypes($xmlDoc));
}
/**
* Get types from XML
*/
protected function getTypes(\DOMDocument $xmlDoc): array
{
$types = $xmlDoc->getElementsByTagName('type');
$typeArr = [];
foreach ($types as $type) {
$typeId = $type->getAttribute('id');
$typeName = $type->getAttribute('name');
if (!$typeName) {
$typeName = $typeId;
}
$properties = $type->getElementsByTagName('property');
$propArr = [];
foreach ($properties as $property) {
$p = [];
$p['name'] = $property->getAttribute('name');
$p['type'] = $property->getAttribute('type');
$propArr[$property->getAttribute('name')] = $p;
}
$typeArr[$typeId] = [];
$typeArr[$typeId]['properties'] = $propArr;
$typeArr[$typeId]['name'] = $typeName;
if ($type->hasAttribute('extends')) {
$typeArr[$typeId]['extends'] = $type->getAttribute('extends');
}
}
return $typeArr;
}
}
@@ -0,0 +1,50 @@
<?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\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Backend\ColorScheme;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Http\Response;
#[AsController]
class ColorSchemeController
{
public function updateAction(ServerRequestInterface $request): ResponseInterface
{
$colorScheme = $request->getParsedBody()['colorScheme'];
if ($request->getMethod() !== 'POST' || !ColorScheme::tryFrom($colorScheme)) {
return new JsonResponse(null, 400);
}
$backendUser = $this->getBackendUser();
$backendUser->uc['colorScheme'] = $colorScheme;
$backendUser->writeUC();
return new Response(null);
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,242 @@
<?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\Controller;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\View\ViewInterface;
/**
* Controller for handling the display column selection for records, typically executed from list modules.
*
* @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
*/
#[AsController]
readonly class ColumnSelectorController
{
private const array PSEUDO_FIELDS = ['_REF_', '_PATH_'];
private const array EXCLUDE_FILE_FIELDS = [
'pid', // Not relevant as all records are on pid=0
'identifier', // Handled manually in listing
'name', // Handled manually in listing
'metadata', // The reference to the meta data is not relevant
'file', // The reference to the file is not relevant
'sys_language_uid', // Not relevant in listing since only default is displayed
'l10n_parent', // Not relevant in listing
't3ver_state', // Not relevant in listing
't3ver_wsid', // Not relevant in listing
't3ver_oid', // Not relevant in listing
];
public function __construct(
protected ResponseFactoryInterface $responseFactory,
protected BackendViewFactory $backendViewFactory,
protected TcaSchemaFactory $tcaSchemaFactory,
) {}
/**
* Update the columns to be displayed for the given table
*/
public function updateVisibleColumnsAction(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$table = (string)($parsedBody['table'] ?? '');
$selectedColumns = $parsedBody['selectedColumns'] ?? [];
if ($table === '' || !is_array($selectedColumns)) {
return $this->jsonResponse([
'success' => false,
'message' => htmlspecialchars(
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_column_selector.xlf:updateColumnView.nothingUpdated')
),
]);
}
$backendUser = $this->getBackendUserAuthentication();
$displayFields = $backendUser->getModuleData('list/displayFields');
$displayFields[$table] = $selectedColumns;
$backendUser->pushModuleData('list/displayFields', $displayFields);
return $this->jsonResponse(['success' => true]);
}
/**
* Generate the show columns selector form
*/
public function showColumnsSelectorAction(ServerRequestInterface $request): ResponseInterface
{
$queryParams = $request->getQueryParams();
$table = (string)($queryParams['table'] ?? '');
if ($table === '') {
throw new \RuntimeException('No table was given for selecting columns', 1625169125);
}
$view = $this->backendViewFactory->create($request);
$view->assignMultiple([
'table' => $table,
'columns' => $this->getColumns($table, (int)($queryParams['id'] ?? 0)),
]);
return $this->htmlResponse($view);
}
/**
* Retrieve all columns for the table, which can be selected
*/
protected function getColumns(string $table, int $pageId): array
{
$tsConfig = BackendUtility::getPagesTSconfig($pageId);
// Current fields selection
$displayFields = $this->getBackendUserAuthentication()->getModuleData('list/displayFields')[$table] ?? [];
if ($table === '_FILE') {
// Special handling for _FILE (merging sys_file and sys_file_metadata together)
$fields = $this->getFileFields();
} else {
// Request fields from table and add pseudo fields
$fields = array_merge(BackendUtility::getAllowedFieldsForTable($table), self::PSEUDO_FIELDS);
}
$columns = $specialColumns = $disabledColumns = [];
foreach ($fields as $fieldName) {
$concreteTableName = $table;
// In case we deal with _FILE, the field name is prefixed with the
// concrete table name, which is either sys_file or sys_file_metadata.
if ($table === '_FILE') {
[$concreteTableName, $fieldName] = explode('|', $fieldName);
}
// Hide field if disabled
if ($tsConfig['TCEFORM.'][$concreteTableName . '.'][$fieldName . '.']['disabled'] ?? false) {
continue;
}
$schema = $this->tcaSchemaFactory->get($concreteTableName);
$labelFieldName = false;
if ($schema->hasCapability(TcaSchemaCapability::Label)) {
$labelFieldName = $schema->getCapability(TcaSchemaCapability::Label)->getPrimaryFieldName();
}
// Determine if the column should be disabled (Meaning it is always selected and can not be turned off)
$isDisabled = $fieldName === $labelFieldName;
// Determine field label
$label = ($schema->hasField($fieldName) ? $schema->getField($fieldName)->getLabel() : '') ?: null;
$label = $this->getLanguageService()->translateLabel(
$tsConfig['TCEFORM.'][$concreteTableName . '.'][$fieldName . '.']['label.'] ?? [],
$tsConfig['TCEFORM.'][$concreteTableName . '.'][$fieldName . '.']['label']
?? $label
?? 'LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.' . $fieldName
);
// Add configuration for this column
$columnConfiguration = [
'name' => $fieldName,
'selected' => $isDisabled || in_array($fieldName, $displayFields, true),
'disabled' => $isDisabled,
'pseudo' => in_array($fieldName, self::PSEUDO_FIELDS, true),
'label' => $label,
];
// Add column configuration to the correct group
if ($columnConfiguration['disabled']) {
$disabledColumns[] = $columnConfiguration;
} elseif (!$columnConfiguration['label']) {
$specialColumns[] = $columnConfiguration;
} else {
$columns[] = $columnConfiguration;
}
}
// Sort standard columns by their resolved label
usort($columns, static fn($a, $b) => $a['label'] <=> $b['label']);
// Disabled columns go first, followed by standard columns
// and special columns, which do not have a label.
return array_merge($disabledColumns, $columns, $specialColumns);
}
/**
* Get file related fields by merging sys_file and sys_file_metadata together
* and adding the corresponding table as prefix (needed for labels processing).
*/
protected function getFileFields(): array
{
// Get all sys_file fields expect excluded ones
$fileFields = array_filter(
BackendUtility::getAllowedFieldsForTable('sys_file'),
static fn(string $field): bool => !in_array($field, self::EXCLUDE_FILE_FIELDS, true)
);
// Always add crdate and tstamp fields for files
$fileFields = array_unique(array_merge($fileFields, ['crdate', 'tstamp']));
// Update the exclude fields with the fields, already added through sys_file, since those take precedence
$excludeFields = array_merge($fileFields, self::EXCLUDE_FILE_FIELDS);
// Get all sys_file_metadata fields expect excluded ones
$fileMetaDataFields = array_filter(
BackendUtility::getAllowedFieldsForTable('sys_file_metadata'),
static fn(string $field): bool => !in_array($field, $excludeFields, true)
);
// Merge sys_file and sys_file_metadata fields together, while adding the table name as prefix
return array_merge(
array_map(static fn(string $value): string => 'sys_file|' . $value, $fileFields),
array_map(static fn(string $value): string => 'sys_file_metadata|' . $value, $fileMetaDataFields),
);
}
protected function htmlResponse(ViewInterface $view): ResponseInterface
{
$response = $this->responseFactory
->createResponse()
->withHeader('Content-Type', 'text/html; charset=utf-8');
$response->getBody()->write($view->render('ColumnSelector'));
return $response;
}
protected function jsonResponse(array $data): ResponseInterface
{
$response = $this->responseFactory
->createResponse()
->withAddedHeader('Content-Type', 'application/json; charset=utf-8');
$response->getBody()->write(json_encode($data));
return $response;
}
protected function getBackendUserAuthentication(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
@@ -0,0 +1,554 @@
<?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\Controller\ContentElement;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\History\RecordHistory;
use TYPO3\CMS\Backend\History\RecordHistoryRollback;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\Components\ButtonBar;
use TYPO3\CMS\Backend\Template\Components\ComponentFactory;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\ValueFormatter\FlexFormValueFormatter;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\DataHandling\History\RecordHistoryStore;
use TYPO3\CMS\Core\DataHandling\TableColumnType;
use TYPO3\CMS\Core\Domain\DateTimeFactory;
use TYPO3\CMS\Core\Exception\SiteNotFoundException;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\DiffGranularity;
use TYPO3\CMS\Core\Utility\DiffUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Controller for showing the history module of TYPO3s backend.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class ElementHistoryController
{
protected RecordHistory $historyObject;
/**
* Display inline differences or not
*/
protected bool $showDiff = true;
protected array $recordCache = [];
protected ModuleTemplate $view;
protected string $returnUrl = '';
public function __construct(
protected readonly IconFactory $iconFactory,
protected readonly UriBuilder $uriBuilder,
protected readonly ModuleTemplateFactory $moduleTemplateFactory,
private readonly DiffUtility $diffUtility,
private readonly FlexFormValueFormatter $flexFormValueFormatter,
private readonly TcaSchemaFactory $tcaSchemaFactory,
private readonly ComponentFactory $componentFactory,
private readonly SiteFinder $siteFinder,
) {}
/**
* Injects the request object for the current request or sub request
* As this controller goes only through the main() method, it is rather simple for now
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$this->view = $this->moduleTemplateFactory->create($request);
$backendUser = $this->getBackendUser();
$this->view->getDocHeaderComponent()->setPageBreadcrumb([]);
$parsedBody = $request->getParsedBody();
$queryParams = $request->getQueryParams();
$this->returnUrl = GeneralUtility::sanitizeLocalUrl($parsedBody['returnUrl'] ?? $queryParams['returnUrl'] ?? '', $request);
$lastHistoryEntry = (int)($parsedBody['historyEntry'] ?? $queryParams['historyEntry'] ?? 0);
$rollbackFields = $parsedBody['rollbackFields'] ?? $queryParams['rollbackFields'] ?? null;
$element = $parsedBody['element'] ?? $queryParams['element'] ?? null;
$moduleSettings = $this->processSettings($request);
$this->view->assign('isUserInWorkspace', $backendUser->workspace > 0);
$this->showDiff = (bool)$moduleSettings['showDiff'];
// Start history object
$this->historyObject = GeneralUtility::makeInstance(RecordHistory::class, $element);
$this->historyObject->setShowSubElements((bool)$moduleSettings['showSubElements']);
$this->historyObject->setLastHistoryEntryNumber($lastHistoryEntry);
if ($moduleSettings['maxSteps']) {
$this->historyObject->setMaxSteps((int)$moduleSettings['maxSteps']);
}
// Do the actual logic now (rollback, show a diff for certain changes,
// or show the full history of a page or a specific record)
$changeLog = $this->historyObject->getChangeLog();
if (!empty($changeLog)) {
if ($rollbackFields !== null) {
$diff = $this->historyObject->getDiff($changeLog);
GeneralUtility::makeInstance(RecordHistoryRollback::class)->performRollback($rollbackFields, $diff);
} elseif ($lastHistoryEntry) {
$completeDiff = $this->historyObject->getDiff($changeLog);
$this->displayMultipleDiff($completeDiff);
$button = $this->componentFactory->createLinkButton()
->setHref($this->buildUrl(['historyEntry' => '']))
->setIcon($this->iconFactory->getIcon('actions-view-go-back', IconSize::SMALL))
->setTitle($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_show_rechis.xlf:fullView'))
->setShowLabelText(true);
$this->view->addButtonToButtonBar($button);
}
if ($this->historyObject->getElementString() !== '') {
$this->displayHistory($changeLog);
}
}
$elementData = $this->historyObject->getElementInformation();
$editLock = false;
if (!empty($elementData)) {
[$elementTable, $elementUid] = $elementData;
$elementUid = (int)$elementUid;
$this->setPagePath($elementTable, $elementUid);
$editLock = $this->getEditLockFromElement($elementTable, $elementUid);
// Get link to page history if the element history is shown
if ($elementTable !== 'pages') {
$parentPage = BackendUtility::getRecord($elementTable, $elementUid, '*', '', false);
if ($parentPage['pid'] > 0 && BackendUtility::readPageAccess($parentPage['pid'], $backendUser->getPagePermsClause(Permission::PAGE_SHOW))) {
$button = $this->componentFactory->createLinkButton()
->setHref($this->buildUrl([
'element' => 'pages:' . $parentPage['pid'],
'historyEntry' => '',
]))
->setIcon($this->iconFactory->getIcon('apps-pagetree-page-default', IconSize::SMALL))
->setTitle($this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_show_rechis.xlf:elementHistory_link'))
->setShowLabelText(true);
$this->view->addButtonToButtonBar($button, ButtonBar::BUTTON_POSITION_LEFT, 2);
}
}
}
if ($element !== null) {
$this->addLanguageSwitcher($request, $backendUser, $element);
}
$this->view->assign('editLock', $editLock);
$this->view->assign('moduleSettings', $moduleSettings);
$this->view->assign('settingsFormUrl', $this->buildUrl());
// Setting up the buttons and markers for docheader
$this->getButtons();
return $this->view->renderResponse('RecordHistory/Main');
}
/**
* Creates the correct path to the current record
*/
protected function setPagePath(string $table, int $uid): void
{
$record = BackendUtility::getRecord($table, $uid, '*', '', false);
if ($table === 'pages') {
$pageId = $uid;
} else {
$pageId = $record['pid'];
}
$pageAccess = BackendUtility::readPageAccess($pageId, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW));
if (is_array($pageAccess)) {
$this->view->getDocHeaderComponent()->setPageBreadcrumb($pageAccess);
}
$schema = $this->tcaSchemaFactory->get($table);
$this->view->assignMultiple([
'recordTable' => $table,
'recordTableReadable' => $schema->getTitle($this->getLanguageService()->sL(...)),
'recordUid' => $uid,
'recordTitle' => $this->generateTitle($table, (string)$uid),
]);
}
protected function getButtons(): void
{
if ($this->returnUrl) {
$backButton = $this->componentFactory->createLinkButton()
->setHref($this->returnUrl)
->setTitle($this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:rm.closeDoc'))
->setShowLabelText(true)
->setIcon($this->iconFactory->getIcon('actions-close', IconSize::SMALL));
$this->view->addButtonToButtonBar($backButton);
}
}
protected function processSettings(ServerRequestInterface $request): array
{
// Get current selection from UC, merge data, write it back to UC
$currentSelection = $this->getBackendUser()->getModuleData('history');
if (!is_array($currentSelection)) {
$currentSelection = ['maxSteps' => '', 'showDiff' => 1, 'showSubElements' => 1];
}
$currentSelectionOverride = $request->getParsedBody()['settings'] ?? null;
if (is_array($currentSelectionOverride) && !empty($currentSelectionOverride)) {
$currentSelection = array_merge($currentSelection, $currentSelectionOverride);
$this->getBackendUser()->pushModuleData('history', $currentSelection);
}
return $currentSelection;
}
/**
* Add a translation selection dropdown if the record is language aware.
*/
protected function addLanguageSwitcher(
ServerRequestInterface $request,
BackendUserAuthentication $backendUser,
string $element,
): void {
$translations = $this->historyObject->getTranslations($element);
if ($translations === null) {
return;
}
$languageDropDownButton = $this->componentFactory->createDropDownButton()
->setLabel($this->getLanguageService()->sL('core.core:labels.language'))
->setShowLabelText(true);
try {
$site = $this->siteFinder->getSiteByPageId($translations['page']);
} catch (SiteNotFoundException) {
$site = $request->getAttribute('site');
}
$availableLanguages = $site->getAvailableLanguages($backendUser, false, $translations['page']);
foreach ($translations['elements'] as $translation) {
$siteLanguage = $availableLanguages[$translation['language']] ?? null;
if (!$siteLanguage instanceof SiteLanguage) {
continue;
}
$languageItem = $this->componentFactory->createDropDownRadio()
->setActive($translation['element'] === $element)
->setIcon($this->iconFactory->getIcon($siteLanguage->getFlagIdentifier()))
->setHref((string)$this->uriBuilder->buildUriFromRoute('record_history', [
'element' => $translation['element'],
'returnUrl' => $this->returnUrl,
]))
->setLabel($siteLanguage->getTitle());
$languageDropDownButton->addItem($languageItem);
if ($languageItem->isActive()) {
$languageDropDownButton->setLabel($siteLanguage->getTitle());
}
}
$this->view->getDocHeaderComponent()->setLanguageSelector($languageDropDownButton);
}
/**
* Displays a diff over multiple fields including rollback links
*
* @param array $diff Difference array
*/
protected function displayMultipleDiff(array $diff): void
{
$languageService = $this->getLanguageService();
// Get all array keys needed
/** @var string[] $arrayKeys */
$arrayKeys = array_merge(array_keys($diff['newData']), array_keys($diff['insertsDeletes']), array_keys($diff['oldData']));
$arrayKeys = array_unique($arrayKeys);
if (!empty($arrayKeys)) {
$lines = [];
foreach ($arrayKeys as $key) {
$singleLine = [];
$elParts = explode(':', $key);
// Turn around diff because it should be a "rollback preview"
if ((int)($diff['insertsDeletes'][$key] ?? 0) === 1) {
// insert
$singleLine['insertDelete'] = 'delete';
} elseif ((int)($diff['insertsDeletes'][$key] ?? 0) === -1) {
$singleLine['insertDelete'] = 'insert';
}
// Build up temporary diff array
// turn around diff because it should be a "rollback preview"
if ($diff['newData'][$key] ?? false) {
$tmpArr = [
'newRecord' => $diff['oldData'][$key],
'oldRecord' => $diff['newData'][$key],
];
// show changes
if (!$this->showDiff) {
// Display field names instead of full diff
// Re-write field names with labels
/** @var string[] $tmpFieldList */
$tmpFieldList = array_keys($tmpArr['newRecord']);
foreach ($tmpFieldList as $fieldKey => $value) {
$itemLabel = '';
if ($this->tcaSchemaFactory->has($elParts[0]) && ($schema = $this->tcaSchemaFactory->get($elParts[0]))->hasField($value)) {
$itemLabel = $schema->getField($value)->getLabel();
}
$tmp = str_replace(':', '', $languageService->sL($itemLabel));
if ($tmp) {
$tmpFieldList[$fieldKey] = $tmp;
} else {
// remove fields if no label available
unset($tmpFieldList[$fieldKey]);
}
}
$singleLine['fieldNames'] = implode(',', $tmpFieldList);
} else {
// Display diff
$singleLine['differences'] = $this->renderDiff($tmpArr, $elParts[0], (int)$elParts[1], true);
}
}
$elParts = explode(':', $key);
$singleLine['revertRecordUrl'] = $this->buildUrl(['rollbackFields' => $key]);
$singleLine['title'] = $this->generateTitle($elParts[0], $elParts[1]);
$singleLine['recordTable'] = $elParts[0];
$singleLine['recordUid'] = $elParts[1];
$lines[] = $singleLine;
}
$this->view->assign('revertAllUrl', $this->buildUrl(['rollbackFields' => 'ALL']));
$this->view->assign('multipleDiff', $lines);
}
$this->view->assign('showDifferences', true);
}
/**
* Shows the full change log
*/
protected function displayHistory(array $historyEntries): void
{
if ($historyEntries === []) {
return;
}
$languageService = $this->getLanguageService();
$lines = [];
$beUserArray = BackendUtility::getUserNames('username,realName,usergroup,uid');
// Traverse changeLog array:
foreach ($historyEntries as $entry) {
// Build up single line
$singleLine = [];
// Get user names
$singleLine['backendUserUid'] = $entry['userid'];
$singleLine['backendUserName'] = $beUserArray[$entry['userid']]['username'] ?? '';
$singleLine['backendUserRealName'] = $beUserArray[$entry['userid']]['realName'] ?? '';
// Executed by switch user
if (!empty($entry['originaluserid'])) {
$singleLine['originalBackendUserUid'] = $entry['originaluserid'];
$singleLine['originalBackendUserName'] = $beUserArray[$entry['originaluserid']]['username'] ?? '';
$singleLine['originalBackendRealName'] = $beUserArray[$entry['originaluserid']]['realName'] ?? '';
}
// Is a change in a workspace?
$singleLine['isChangedInWorkspace'] = (int)$entry['workspace'] > 0;
// Diff link
$singleLine['diffUrl'] = $this->buildUrl(['historyEntry' => $entry['uid']]);
// Add time
$singleLine['day'] = BackendUtility::date($entry['tstamp']);
$singleLine['timestamp'] = DateTimeFactory::createFromTimestamp($entry['tstamp']);
$singleLine['title'] = $this->generateTitle($entry['tablename'], (string)$entry['recuid']);
$singleLine['recordTable'] = $entry['tablename'];
$singleLine['recordUid'] = $entry['recuid'];
$singleLine['elementUrl'] = $this->buildUrl(['element' => $entry['tablename'] . ':' . $entry['recuid']]);
$singleLine['actiontype'] = $entry['actiontype'];
if ((int)$entry['actiontype'] === RecordHistoryStore::ACTION_MODIFY || (int)$entry['actiontype'] === RecordHistoryStore::ACTION_PUBLISH) {
// show changes
if (!$this->showDiff) {
// Display field names instead of full diff
// Re-write field names with labels
/** @var string[] $tmpFieldList */
$tmpFieldList = array_keys($entry['newRecord']);
foreach ($tmpFieldList as $key => $value) {
$itemLabel = '';
if ($this->tcaSchemaFactory->has($entry['tablename']) && ($schema = $this->tcaSchemaFactory->get($entry['tablename']))->hasField($value)) {
$itemLabel = $schema->getField($value)->getLabel();
}
$tmp = str_replace(':', '', $languageService->sL($itemLabel));
if ($tmp) {
$tmpFieldList[$key] = $tmp;
} else {
// remove fields if no label available
unset($tmpFieldList[$key]);
}
}
$singleLine['fieldNames'] = implode(',', $tmpFieldList);
} else {
// Display diff
$singleLine['differences'] = $this->renderDiff($entry, $entry['tablename'], (int)$entry['recuid']);
}
}
// put line together
$lines[] = $singleLine;
}
$this->view->assign('history', $lines);
}
/**
* Renders HTML table-rows with the comparison information of a sys_history entry record
*
* @param array $entry sys_history entry record.
* @param string $table The table name
* @param int $rollbackUid The UID of the record
* @param bool $showRollbackLink Whether a rollback link should be shown for each changed field
* @return array array of records
*/
protected function renderDiff(array $entry, string $table, int $rollbackUid, bool $showRollbackLink = false): array
{
if (!$this->tcaSchemaFactory->has($table)) {
return [];
}
$lines = [];
if (is_array($entry['newRecord'] ?? null)) {
$fieldsToDisplay = array_keys($entry['newRecord']);
$languageService = $this->getLanguageService();
$schema = $this->tcaSchemaFactory->get($table);
foreach ($fieldsToDisplay as $fN) {
if (!$schema->hasField($fN)) {
continue;
}
$fieldInformation = $schema->getField($fN);
if (!$fieldInformation->isType(TableColumnType::PASSTHROUGH)) {
if ($fieldInformation->isType(TableColumnType::FLEX)) {
$colConfig = $fieldInformation->getConfiguration();
$old = $this->flexFormValueFormatter->format($table, $fN, ($entry['oldRecord'][$fN] ?? ''), $rollbackUid, $colConfig);
$new = $this->flexFormValueFormatter->format($table, $fN, ($entry['newRecord'][$fN] ?? ''), $rollbackUid, $colConfig);
$diffResult = $this->diffUtility->diff(strip_tags($old), strip_tags($new), DiffGranularity::CHARACTER);
} else {
$old = (string)BackendUtility::getProcessedValue($table, $fN, ($entry['oldRecord'][$fN] ?? ''), 0, true, false, $rollbackUid);
$new = (string)BackendUtility::getProcessedValue($table, $fN, ($entry['newRecord'][$fN] ?? ''), 0, true, false, $rollbackUid);
$diffResult = $this->diffUtility->diff(strip_tags($old), strip_tags($new));
}
$rollbackUrl = '';
if ($rollbackUid && $showRollbackLink) {
$rollbackUrl = $this->buildUrl(['rollbackFields' => $table . ':' . $rollbackUid . ':' . $fN]);
}
$lines[] = [
'title' => $languageService->sL($fieldInformation->getLabel()),
'rollbackUrl' => $rollbackUrl,
'result' => str_replace('\n', PHP_EOL, str_replace('\r\n', '\n', $diffResult)),
];
}
}
}
return $lines;
}
/**
* Generates the URL for a link to the current page
*/
protected function buildUrl(array $overrideParameters = []): string
{
$params = [];
// Setting default values based on GET parameters:
$elementString = $this->historyObject->getElementString();
if ($elementString !== '') {
$params['element'] = $elementString;
}
$params['historyEntry'] = $this->historyObject->getLastHistoryEntryNumber();
if (!empty($this->returnUrl)) {
$params['returnUrl'] = $this->returnUrl;
}
// Merging overriding values:
$params = array_merge($params, $overrideParameters);
// Make the link:
return (string)$this->uriBuilder->buildUriFromRoute('record_history', $params);
}
/**
* Generates the title and puts the record title behind
*/
protected function generateTitle(string $table, string $uid): string
{
if ($this->tcaSchemaFactory->get($table)->hasCapability(TcaSchemaCapability::Label)) {
$record = $this->getRecord($table, (int)$uid) ?? [];
return BackendUtility::getRecordTitle($table, $record);
}
return '';
}
/**
* Gets a database record (cached).
*/
protected function getRecord(string $table, int $uid): ?array
{
if (!isset($this->recordCache[$table][$uid])) {
$this->recordCache[$table][$uid] = BackendUtility::getRecord($table, $uid, '*', '', false);
}
return $this->recordCache[$table][$uid];
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
/**
* Get the editlock value from page of a history element
*/
protected function getEditLockFromElement(string $tableName, int $elementUid): bool
{
// If the user is admin, then he may always edit the page.
if ($this->getBackendUser()->isAdmin()) {
return false;
}
$schema = $this->tcaSchemaFactory->get($tableName);
// Early return if $elementUid is zero
if ($elementUid === 0) {
return !$schema->getCapability(TcaSchemaCapability::RestrictionRootLevel)->shallIgnoreRootLevelRestriction();
}
$record = BackendUtility::getRecord($tableName, $elementUid, '*', '', false);
// we need the parent page record for the editlock info if element isn't a page
if ($tableName !== 'pages') {
$pageId = $record['pid'];
$record = BackendUtility::getRecord('pages', $pageId, '*', '', false);
}
return $schema->hasCapability(TcaSchemaCapability::EditLock)
&& ($record[$schema->getCapability(TcaSchemaCapability::EditLock)->getFieldName()] ?? false);
}
}
@@ -0,0 +1,797 @@
<?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\Controller\ContentElement;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\History\RecordHistory;
use TYPO3\CMS\Backend\Routing\Exception\RouteNotFoundException;
use TYPO3\CMS\Backend\Routing\PreviewUriBuilder;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Database\Connection;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Resource\File;
use TYPO3\CMS\Core\Resource\FileType;
use TYPO3\CMS\Core\Resource\Folder;
use TYPO3\CMS\Core\Resource\Index\MetaDataRepository;
use TYPO3\CMS\Core\Resource\Rendering\RendererRegistry;
use TYPO3\CMS\Core\Resource\ResourceFactory;
use TYPO3\CMS\Core\Schema\Capability\TcaSchemaCapability;
use TYPO3\CMS\Core\Schema\SearchableSchemaFieldsCollector;
use TYPO3\CMS\Core\Schema\TcaSchema;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Schema\VisibleSchemaFieldsCollector;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Modal rendering detail about a record. Reached by "Display information" on click menu and records module.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class ElementInformationController
{
/**
* Type of element: "db", "file" or "folder"
*/
protected string $type = 'db';
protected array $row = [];
protected ?string $table = null;
protected ?File $fileObject = null;
protected ?Folder $folderObject = null;
public function __construct(
protected readonly IconFactory $iconFactory,
protected readonly UriBuilder $uriBuilder,
protected readonly ModuleTemplateFactory $moduleTemplateFactory,
protected readonly ResourceFactory $resourceFactory,
protected readonly TcaSchemaFactory $tcaSchemaFactory,
protected readonly VisibleSchemaFieldsCollector $visibleSchemaFieldsCollector,
private readonly SearchableSchemaFieldsCollector $searchableSchemaFieldsCollector,
private readonly MetaDataRepository $metaDataRepository,
private readonly ConnectionPool $connectionPool,
private readonly RendererRegistry $rendererRegistry,
) {}
/**
* Injects the request object for the current request or subrequest
* As this controller goes only through the main() method, it is rather simple for now
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$backendUser = $this->getBackendUser();
$view = $this->moduleTemplateFactory->create($request);
$view->getDocHeaderComponent()->disable();
$queryParams = $request->getQueryParams();
$this->table = $queryParams['table'] ?? null;
$uid = $queryParams['uid'] ?? '';
$permsClause = $backendUser->getPagePermsClause(Permission::PAGE_SHOW);
// Determines if table/uid point to database record or file and if user has access to view information
$accessAllowed = false;
if ($this->tcaSchemaFactory->has($this->table)) {
$uid = (int)$uid;
// Check permissions and uid value:
if ($uid && $backendUser->check('tables_select', $this->table)) {
if ((string)$this->table === 'pages') {
$this->row = BackendUtility::readPageAccess($uid, $permsClause) ?: [];
$accessAllowed = $this->row !== [];
} else {
$this->row = BackendUtility::getRecordWSOL($this->table, $uid);
if ($this->row) {
if (isset($this->row['_ORIG_uid'])) {
// Make $uid the uid of the versioned record, while $this->row['uid'] is live record uid
$uid = (int)$this->row['_ORIG_uid'];
}
$pageInfo = BackendUtility::readPageAccess((int)$this->row['pid'], $permsClause) ?: [];
$accessAllowed = $pageInfo !== []
|| ((int)$this->row['pid'] === 0 && $this->tcaSchemaFactory->get($this->table)->getCapability(TcaSchemaCapability::RestrictionRootLevel)->shallIgnoreRootLevelRestriction());
}
}
}
} elseif ($this->table === '_FILE' || $this->table === '_FOLDER' || $this->table === 'sys_file') {
$fileOrFolderObject = $this->resourceFactory->retrieveFileOrFolderObject($uid);
if ($fileOrFolderObject instanceof Folder) {
$this->folderObject = $fileOrFolderObject;
$accessAllowed = $this->folderObject->checkActionPermission('read');
$this->type = 'folder';
} elseif ($fileOrFolderObject instanceof File) {
$this->fileObject = $fileOrFolderObject;
$accessAllowed = $this->fileObject->checkActionPermission('read');
$this->type = 'file';
$this->table = 'sys_file';
$this->row = BackendUtility::getRecordWSOL($this->table, $fileOrFolderObject->getUid());
}
}
// Rendering of the output via fluid
$view->assign('accessAllowed', $accessAllowed);
$view->assign('hookContent', '');
if (!$accessAllowed) {
return $view->renderResponse('ContentElement/ElementInformation');
}
// render type by user func
foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['typo3/show_item.php']['typeRendering'] ?? [] as $className) {
$typeRenderObj = GeneralUtility::makeInstance($className);
if (method_exists($typeRenderObj, 'isValid') && method_exists($typeRenderObj, 'render')) {
if ($typeRenderObj->isValid($this->type, $this)) {
$view->assign('hookContent', $typeRenderObj->render($this->type, $this, $view));
return $view->renderResponse('ContentElement/ElementInformation');
}
}
}
$pageTitle = $this->getPageTitle();
$view->setTitle($pageTitle['table'] . ': ' . $pageTitle['title']);
$view->assignMultiple($pageTitle);
$view->assignMultiple($this->getPreview($request));
$view->assignMultiple($this->getPropertiesForTable());
$view->assignMultiple($this->getReferences($request, $uid));
$view->assign('returnUrl', GeneralUtility::sanitizeLocalUrl($request->getQueryParams()['returnUrl'] ?? '', $request));
$view->assign('maxTitleLength', $this->getBackendUser()->uc['titleLen'] ?? 20);
return $view->renderResponse('ContentElement/ElementInformation');
}
/**
* Get page title with icon, table title and record title
*/
public function getPageTitle(): array
{
$pageTitle = [
'title' => BackendUtility::getRecordTitle($this->table, $this->row),
];
if ($this->type === 'folder') {
$pageTitle['title'] = htmlspecialchars($this->folderObject->getName());
$pageTitle['table'] = $this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:folder');
$pageTitle['icon'] = $this->iconFactory->getIconForResource($this->folderObject, IconSize::SMALL)->render();
} elseif ($this->type === 'file') {
$schema = $this->tcaSchemaFactory->get($this->table);
$pageTitle['table'] = $schema->getTitle($this->getLanguageService()->sL(...));
$pageTitle['icon'] = $this->iconFactory->getIconForResource($this->fileObject, IconSize::SMALL)->render();
} else {
$schema = $this->tcaSchemaFactory->get($this->table);
$pageTitle['table'] = $schema->getTitle($this->getLanguageService()->sL(...));
$pageTitle['icon'] = $this->iconFactory->getIconForRecord($this->table, $this->row, IconSize::SMALL);
}
return $pageTitle;
}
public function getTable(): ?string
{
return $this->table;
}
public function getRow(): array
{
return $this->row;
}
public function getFileObject(): ?File
{
return $this->fileObject;
}
public function getFolderObject(): ?Folder
{
return $this->folderObject;
}
/**
* Get preview for current record
*/
protected function getPreview(ServerRequestInterface $request): array
{
$preview = [];
// Perhaps @todo in future: Also display preview for records - without fileObject
if (!$this->fileObject) {
return $preview;
}
// check if file is marked as missing
if ($this->fileObject->isMissing()) {
$preview['missingFile'] = $this->fileObject->getName();
} else {
$fileRenderer = $this->rendererRegistry->getRenderer($this->fileObject);
$preview['url'] = $this->fileObject->getPublicUrl() ?? '';
// Add "edit metadata" button
$preview['editMetadataUrl'] = '';
if (($metaDataUid = $this->fileObject->getProperties()['metadata_uid'] ?? false)
&& $this->fileObject->isIndexed()
&& $this->fileObject->checkActionPermission('editMeta')
&& $this->getBackendUser()->check('tables_modify', 'sys_file_metadata')
) {
$urlParameters = [
'edit' => [
'sys_file_metadata' => [
$metaDataUid => 'edit',
],
],
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
];
$preview['editMetadataUrl'] = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
}
$width = min(590, $this->fileObject->getMetaData()['width'] ?? 590) . 'm';
$height = min(400, $this->fileObject->getMetaData()['height'] ?? 400) . 'm';
// Check if there is a FileRenderer
if ($fileRenderer !== null) {
$preview['fileRenderer'] = $fileRenderer->render($this->fileObject, $width, $height);
// else check if we can create an Image preview
} elseif ($this->fileObject->isImage()) {
$preview['fileObject'] = $this->fileObject;
$preview['width'] = $width;
$preview['height'] = $height;
}
}
return $preview;
}
/**
* Get property array for html table
*/
protected function getPropertiesForTable(): array
{
$lang = $this->getLanguageService();
$propertiesForTable = [];
$propertiesForTable['extraFields'] = $this->getExtraFields();
// Traverse the list of fields to display for the record:
$fieldList = $this->getFieldList($this->table, $this->row);
$schema = $this->tcaSchemaFactory->has($this->table) ? $this->tcaSchemaFactory->get($this->table) : null;
foreach ($fieldList as $name) {
$name = trim($name);
$uid = $this->row['uid'] ?? 0;
if (!$schema?->hasField($name)) {
continue;
}
// @todo Add meaningful information for mfa field. For the time being we don't display anything at all.
if ($this->type === 'db' && $name === 'mfa' && in_array($this->table, ['be_users', 'fe_users'], true)) {
continue;
}
// not a real field -> skip
if ($this->type === 'file' && $name === 'fileinfo') {
continue;
}
// handled explicitly below with proper byte formatting -> skip
if ($this->type === 'file' && $name === 'size') {
continue;
}
// Field does not exist (e.g. having type=none) -> skip
if (!array_key_exists($name, $this->row)) {
continue;
}
$label = $lang->sL($schema->getField($name)->getLabel());
$label = $label ?: $name;
$propertiesForTable['fields'][] = [
'fieldValue' => BackendUtility::getProcessedValue($this->table, $name, $this->row[$name], 0, false, false, $uid, true, 0, $this->row),
'fieldLabel' => htmlspecialchars($label),
];
}
// additional information for folders and files
if ($this->folderObject instanceof Folder || $this->fileObject instanceof File) {
// storage
if ($this->folderObject instanceof Folder) {
$propertiesForTable['fields']['storage'] = [
'fieldValue' => $this->folderObject->getStorage()->getName(),
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_tca.xlf:sys_file.storage')),
];
}
// folder
$resourceObject = $this->fileObject ?: $this->folderObject;
$parentFolder = $resourceObject->getParentFolder();
$propertiesForTable['fields']['folder'] = [
'fieldValue' => $parentFolder->getReadablePath(),
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_common.xlf:folder')),
];
if ($this->fileObject instanceof File) {
// show file dimensions for images
if ($this->fileObject->isType(FileType::IMAGE)) {
$propertiesForTable['fields']['width'] = [
'fieldValue' => $this->fileObject->getProperty('width') . 'px',
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.width')),
];
$propertiesForTable['fields']['height'] = [
'fieldValue' => $this->fileObject->getProperty('height') . 'px',
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.height')),
];
}
// file size
$fileSizeInBytes = (int)$this->fileObject->getProperty('size');
$propertiesForTable['fields']['size'] = [
'fieldValue' => sprintf(
'%s (%s)',
GeneralUtility::formatSize($fileSizeInBytes, htmlspecialchars($this->getLanguageService()->sL('core.common:byteSizeUnits'))),
htmlspecialchars($lang->translate('size_in_bytes', 'core.core', ['numberOfBytes' => GeneralUtility::formatSize($fileSizeInBytes, ' ')])),
),
'fieldLabel' => $lang->sL($schema?->hasField('size') ? $schema->getField('size')->getLabel() : ''),
];
// show the metadata of a file as well
$metaData = $this->metaDataRepository->findByFileUid((int)($this->row['uid'] ?? 0));
// If there is no metadata record, skip it
if ($metaData !== []) {
$fileMetadataSchema = $this->tcaSchemaFactory->get('sys_file_metadata');
$allowedFields = $this->getFieldList('sys_file_metadata', $metaData);
foreach ($metaData as $name => $value) {
if (!in_array($name, $allowedFields, true)) {
continue;
}
if ($name === 'crdate') {
// Is of type=passthrough and already part of
// meta information displayed on top of the table
continue;
}
if (!$fileMetadataSchema->hasField($name)) {
continue;
}
$label = $lang->sL($fileMetadataSchema->getField($name)->getLabel());
$label = $label ?: $name;
$propertiesForTable['fields'][] = [
'fieldValue' => BackendUtility::getProcessedValue('sys_file_metadata', $name, $value, 0, false, false, (int)$metaData['uid'], true, 0, $metaData),
'fieldLabel' => htmlspecialchars($label),
];
}
}
}
}
return $propertiesForTable;
}
/**
* Get the list of fields that should be shown for the given table
*/
protected function getFieldList(string $table, array $row): array
{
$fieldNamesToExclude = [];
if ($this->tcaSchemaFactory->has($table)) {
$schema = $this->tcaSchemaFactory->get($table);
if ($schema->hasCapability(TcaSchemaCapability::AncestorReferenceField)) {
$fieldNamesToExclude[] = $schema->getCapability(TcaSchemaCapability::AncestorReferenceField)->getFieldName();
}
if ($schema->isLanguageAware()) {
$languageCapability = $schema->getCapability(TcaSchemaCapability::Language);
$fieldNamesToExclude[] = $languageCapability->getTranslationOriginPointerField()->getName();
if ($languageCapability->hasDiffSourceField()) {
$fieldNamesToExclude[] = $languageCapability->getDiffSourceField()?->getName();
}
}
}
return $this->searchableSchemaFieldsCollector->getUniqueFieldList(
$table,
$this->visibleSchemaFieldsCollector->getFieldNames($table, $row, $fieldNamesToExclude),
false
);
}
/**
* Get the extra fields (uid, timestamps, creator) for the table
*/
protected function getExtraFields(): array
{
$lang = $this->getLanguageService();
$keyLabelPair = [];
if (in_array($this->type, ['folder', 'file'], true)) {
if ($this->type === 'file') {
$keyLabelPair['uid'] = [
'value' => (int)$this->row['uid'],
];
$keyLabelPair['creation_date'] = [
'value' => BackendUtility::datetime($this->row['creation_date']),
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.creationDate')),
'isDatetime' => true,
];
$keyLabelPair['modification_date'] = [
'value' => BackendUtility::datetime($this->row['modification_date']),
'fieldLabel' => htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.timestamp')),
'isDatetime' => true,
];
} else {
$keyLabelPair['uid'] = [
'value' => $this->folderObject->getCombinedIdentifier(),
];
}
} else {
$keyLabelPair['uid'] = [
'value' => BackendUtility::getProcessedValueExtra($this->table, 'uid', $this->row['uid']),
'fieldLabel' => rtrim(htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:show_item.php.uid')), ':'),
];
$schema = $this->tcaSchemaFactory->get($this->table);
if ($schema->hasCapability(TcaSchemaCapability::CreatedAt)) {
$field = $schema->getCapability(TcaSchemaCapability::CreatedAt)->getFieldName();
$keyLabelPair[$field] = [
'value' => BackendUtility::datetime($this->row[$field]),
'fieldLabel' => rtrim(htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.creationDate')), ':'),
'isDatetime' => true,
];
}
if ($schema->hasCapability(TcaSchemaCapability::UpdatedAt)) {
$field = $schema->getCapability(TcaSchemaCapability::UpdatedAt)->getFieldName();
$keyLabelPair[$field] = [
'value' => BackendUtility::datetime($this->row[$field]),
'fieldLabel' => rtrim(htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.timestamp')), ':'),
'isDatetime' => true,
];
}
// Show the user who created the record
$recordHistory = GeneralUtility::makeInstance(RecordHistory::class);
$ownerInformation = $recordHistory->getCreationInformationForRecord($this->table, $this->row);
$ownerUid = (int)(is_array($ownerInformation) && $ownerInformation['usertype'] === 'BE' ? $ownerInformation['userid'] : 0);
if ($ownerUid) {
$creatorRecord = BackendUtility::getRecord('be_users', $ownerUid);
if ($creatorRecord) {
$keyLabelPair['creatorRecord'] = [
'value' => $creatorRecord,
'fieldLabel' => rtrim(htmlspecialchars($lang->sL('LLL:EXT:core/Resources/Private/Language/locallang_general.xlf:LGL.creationUserId')), ':'),
];
}
}
}
return $keyLabelPair;
}
/**
* Get references section (references from and references to current record)
*/
protected function getReferences(ServerRequestInterface $request, int|string $uid): array
{
$references = [];
switch ($this->type) {
case 'db': {
$references['refLines'] = $this->makeRef($this->table, $uid, $request);
$references['refFromLines'] = $this->makeRefFrom($this->table, $uid, $request);
break;
}
case 'file': {
if ($this->fileObject && $this->fileObject->isIndexed()) {
$references['refLines'] = $this->makeRef('_FILE', $this->fileObject, $request);
}
break;
}
}
return $references;
}
/**
* Get field name for specified table/column name
*
* @param string $fieldName Column name
*/
protected function getLabelForTableColumn(TcaSchema $schema, string $fieldName): string
{
if ($schema->hasField($fieldName)) {
$field = $schema->getField($fieldName);
$field = $field->getLabel() ? $this->getLanguageService()->sL($field->getLabel()) : $fieldName;
if (trim($field) === '') {
$field = $fieldName;
}
} else {
$field = $fieldName;
}
return $field;
}
/**
* Returns the record actions
*
* @param int $uid
* @throws RouteNotFoundException
*/
protected function getRecordActions(TcaSchema $schema, $uid, ServerRequestInterface $request): array
{
if ($uid < 0) {
return [];
}
$actions = [];
// Edit button
$urlParameters = [
'edit' => [
$schema->getName() => [
$uid => 'edit',
],
],
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
];
$actions['recordEditUrl'] = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
// History button
$urlParameters = [
'element' => $schema->getName() . ':' . $uid,
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
];
$actions['recordHistoryUrl'] = (string)$this->uriBuilder->buildUriFromRoute('record_history', $urlParameters);
if ($schema->getName() === 'pages') {
// Recordlist button
$actions['recordsModuleUrl'] = (string)$this->uriBuilder->buildUriFromRoute('records', ['id' => $uid, 'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri()]);
// retrieve record to get page language
$record = BackendUtility::getRecord($schema->getName(), $uid);
$previewUriBuilder = PreviewUriBuilder::create($record)
->withRootLine(BackendUtility::BEgetRootLine($uid));
// View page button
$actions['previewUrlAttributes'] = $previewUriBuilder->serializeDispatcherAttributes();
}
return $actions;
}
/**
* Make reference display
*
* @param string $table Table name
* @param int|File $ref Filename or uid
* @throws RouteNotFoundException
*/
protected function makeRef(string $table, $ref, ServerRequestInterface $request): array
{
$refLines = [];
$lang = $this->getLanguageService();
// Files reside in sys_file table
if ($table === '_FILE') {
$selectTable = 'sys_file';
$selectUid = $ref->getUid();
} else {
$selectTable = $table;
$selectUid = $ref;
}
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('sys_refindex');
$predicates = [
$queryBuilder->expr()->eq(
'ref_table',
$queryBuilder->createNamedParameter($selectTable)
),
$queryBuilder->expr()->eq(
'ref_uid',
$queryBuilder->createNamedParameter($selectUid, Connection::PARAM_INT)
),
];
$backendUser = $this->getBackendUser();
if (!$backendUser->isAdmin()) {
$allowedSelectTables = GeneralUtility::trimExplode(',', $backendUser->groupData['tables_select']);
$predicates[] = $queryBuilder->expr()->in(
'tablename',
$queryBuilder->createNamedParameter($allowedSelectTables, Connection::PARAM_STR_ARRAY)
);
}
$rows = $queryBuilder
->select('*')
->from('sys_refindex')
->where(...$predicates)
->executeQuery()
->fetchAllAssociative();
// Compile information for title tag:
foreach ($rows as $row) {
if ($row['tablename'] === 'sys_file_reference') {
$row = $this->transformFileReferenceToRecordReference($row);
if ($row === null) {
continue;
}
}
if (!$this->tcaSchemaFactory->has($row['tablename'])) {
continue;
}
$schema = $this->tcaSchemaFactory->get($row['tablename']);
$line = [];
$record = BackendUtility::getRecordWSOL($row['tablename'], $row['recuid']);
if ($record) {
if (!$this->canAccessPage($schema, $record)) {
continue;
}
$parentRecord = BackendUtility::getRecord('pages', $record['pid']);
$parentRecordTitle = is_array($parentRecord)
? BackendUtility::getRecordTitle('pages', $parentRecord)
: '';
$urlParameters = [
'edit' => [
$row['tablename'] => [
$row['recuid'] => 'edit',
],
],
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
];
$url = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
$line['url'] = $url;
$line['icon'] = $this->iconFactory->getIconForRecord($row['tablename'], $record, IconSize::SMALL)->render();
$line['row'] = $row;
$line['record'] = $record;
$line['recordTitle'] = BackendUtility::getRecordTitle($row['tablename'], $record);
$line['parentRecord'] = $parentRecord;
$line['parentRecordTitle'] = $parentRecordTitle;
$line['title'] = $schema->getTitle($lang->sL(...)) ?: $row['tablename'];
$line['labelForTableColumn'] = $this->getLabelForTableColumn($schema, $row['field']);
$line['path'] = BackendUtility::getRecordPath($record['pid'], '', 0, 0);
$line['actions'] = $this->getRecordActions($schema, $row['recuid'], $request);
} else {
$line['row'] = $row;
$line['title'] = $schema->getTitle($lang->sL(...)) ?: $row['tablename'];
$line['labelForTableColumn'] = $this->getLabelForTableColumn($schema, $row['field']);
}
$refLines[] = $line;
}
return $refLines;
}
/**
* Make reference display (what this elements points to)
*
* @param string $table Table name
* @param int $ref Filename or uid
*/
protected function makeRefFrom($table, $ref, ServerRequestInterface $request): array
{
$refFromLines = [];
$lang = $this->getLanguageService();
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('sys_refindex');
$predicates = [
$queryBuilder->expr()->eq(
'tablename',
$queryBuilder->createNamedParameter($table)
),
$queryBuilder->expr()->eq(
'recuid',
$queryBuilder->createNamedParameter($ref, Connection::PARAM_INT)
),
];
$backendUser = $this->getBackendUser();
if (!$backendUser->isAdmin()) {
$allowedSelectTables = GeneralUtility::trimExplode(',', $backendUser->groupData['tables_select']);
$predicates[] = $queryBuilder->expr()->in(
'ref_table',
$queryBuilder->createNamedParameter($allowedSelectTables, Connection::PARAM_STR_ARRAY)
);
}
$rows = $queryBuilder
->select('*')
->from('sys_refindex')
->where(...$predicates)
->executeQuery()
->fetchAllAssociative();
// Compile information for title tag:
foreach ($rows as $row) {
$line = [];
$record = BackendUtility::getRecordWSOL($row['ref_table'], $row['ref_uid']);
if (!$this->tcaSchemaFactory->has($row['ref_table'])) {
continue;
}
$schema = $this->tcaSchemaFactory->get($row['ref_table']);
if ($record) {
if (!$this->canAccessPage($schema, $record)) {
continue;
}
$urlParameters = [
'edit' => [
$row['ref_table'] => [
$row['ref_uid'] => 'edit',
],
],
'returnUrl' => $request->getAttribute('normalizedParams')->getRequestUri(),
];
$url = (string)$this->uriBuilder->buildUriFromRoute('record_edit', $urlParameters);
$line['url'] = $url;
$line['icon'] = $this->iconFactory->getIconForRecord($row['ref_table'], $record, IconSize::SMALL)->render();
$line['row'] = $row;
$line['record'] = $record;
$line['recordTitle'] = BackendUtility::getRecordTitle($row['ref_table'], $record);
$line['title'] = $schema->getTitle($lang->sL(...));
$line['labelForTableColumn'] = $this->getLabelForTableColumn($schema, $row['field']);
$line['path'] = BackendUtility::getRecordPath($record['pid'], '', 0);
$line['actions'] = $this->getRecordActions($schema, $row['ref_uid'], $request);
} else {
$line['row'] = $row;
$line['title'] = $schema->getTitle($lang->sL(...));
$line['labelForTableColumn'] = $this->getLabelForTableColumn($schema, $row['field']);
}
$refFromLines[] = $line;
}
return $refFromLines;
}
/**
* Convert FAL file reference (sys_file_reference) to reference index (sys_refindex) table format
*/
protected function transformFileReferenceToRecordReference(array $referenceRecord): ?array
{
$queryBuilder = $this->connectionPool
->getQueryBuilderForTable('sys_file_reference');
$queryBuilder->getRestrictions()->removeAll();
$fileReference = $queryBuilder
->select('*')
->from('sys_file_reference')
->where(
$queryBuilder->expr()->eq(
'uid',
$queryBuilder->createNamedParameter($referenceRecord['recuid'], Connection::PARAM_INT)
)
)
->executeQuery()
->fetchAssociative();
return $fileReference ? [
'recuid' => $fileReference['uid_foreign'],
'tablename' => $fileReference['tablenames'],
'field' => $fileReference['fieldname'],
'flexpointer' => '',
'softref_key' => '',
'sorting' => $fileReference['sorting_foreign'],
] : null;
}
/**
* @param array $record Record to be checked (ensure pid is resolved for workspaces)
*/
protected function canAccessPage(TcaSchema $schema, array $record): bool
{
$recordPid = (int)($schema->getName() === 'pages' ? $record['uid'] : $record['pid']);
$isInWebMount = (bool)$this->getBackendUser()->isInWebMount($schema->getName() === 'pages' ? $record : $record['pid']);
return $isInWebMount || ($recordPid === 0 && $schema->getCapability(TcaSchemaCapability::RestrictionRootLevel)->shallIgnoreRootLevelRestriction());
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -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\Backend\Controller\ContentElement;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Template\PageRendererBackendSetupTrait;
use TYPO3\CMS\Backend\Tree\View\ContentMovingPagePositionMap;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Localization\LanguageServiceFactory;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* The "move tt_content element" wizard. Reachable via records module "Re-position content element" on tt_content records.
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
final readonly class MoveElementController
{
use PageRendererBackendSetupTrait;
public function __construct(
private PageRenderer $pageRenderer,
private BackendViewFactory $backendViewFactory,
private LanguageServiceFactory $languageServiceFactory,
private ExtensionConfiguration $extensionConfiguration
) {}
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$this->setUpBasicPageRendererForBackend(
$this->pageRenderer,
$this->extensionConfiguration,
$request,
$this->languageServiceFactory->createFromUserPreferences($this->getBackendUser())
);
$view = $this->backendViewFactory->create($request);
$queryParams = $request->getQueryParams();
$contentOnly = $queryParams['contentOnly'] ?? false;
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/global-event-handler.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/tree/page-browser.js');
$this->pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/wizard/move-content-element.js', 'MoveContentElement')->instance()
);
$this->pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_misc.xlf');
$this->pageRenderer->addInlineLanguageLabelFile('EXT:backend/Resources/Private/Language/Wizards/move_content_elements.xlf');
$view->assignMultiple(array_merge($this->getContentVariables($request), [
'contentOnly' => $contentOnly,
]));
$content = $view->render('ContentElement/MoveElement');
if ($contentOnly) {
return new HtmlResponse($content);
}
$this->pageRenderer->setBodyContent('<body>' . $content);
return new HtmlResponse($this->pageRenderer->render($request));
}
private function getContentVariables(ServerRequestInterface $request): array
{
$queryParams = $request->getQueryParams();
$parsedBody = $request->getParsedBody();
$contentElementUid = (int)($parsedBody['uid'] ?? $queryParams['uid'] ?? 0);
$pageId = (int)($parsedBody['expandPage'] ?? $queryParams['expandPage'] ?? 0);
$sysLanguage = (int)($parsedBody['sys_language'] ?? $queryParams['sys_language'] ?? 0);
$makeCopy = (bool)($parsedBody['makeCopy'] ?? $queryParams['makeCopy'] ?? 0);
$permsClause = $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW);
if (!$contentElementUid) {
return [];
}
$contentElement = BackendUtility::getRecordWSOL('tt_content', $contentElementUid);
$pageInfo = BackendUtility::readPageAccess($pageId, $permsClause);
$contentElementTitle = BackendUtility::getRecordTitle('tt_content', $contentElement);
$assigns = [
'record' => $contentElement,
'makeCopyChecked' => $makeCopy,
'pageInfo' => $pageInfo,
'recordTitle' => BackendUtility::cropToTitleLength($contentElementTitle),
];
if (is_array($pageInfo) && $this->getBackendUser()->isInWebMount($pageInfo['uid'], $permsClause)) {
// Initialize the content position map:
$contentPositionMap = GeneralUtility::makeInstance(ContentMovingPagePositionMap::class);
$contentPositionMap->copyMode = $makeCopy ? 'copy' : 'move';
$contentPositionMap->moveUid = $contentElementUid;
$contentPositionMap->cur_sys_language = $sysLanguage;
$pageTitle = BackendUtility::getRecordTitle('pages', $pageInfo);
$assigns['pageRecord']['recordTooltip'] = BackendUtility::getRecordIconAltText($pageInfo, 'pages', false);
$assigns['pageRecord']['recordTitle'] = BackendUtility::cropToTitleLength($pageTitle);
$assigns['contentElementColumns'] = $contentPositionMap->printContentElementColumns($pageId, $pageInfo, $request);
}
return $assigns;
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,710 @@
<?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\Controller\ContentElement;
use Psr\EventDispatcher\EventDispatcherInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Controller\Event\ModifyNewContentElementWizardItemsEvent;
use TYPO3\CMS\Backend\Form\Utility\FormEngineUtility;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Tree\View\ContentCreationPagePositionMap;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Backend\View\BackendLayoutView;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Schema\Struct\SelectItem;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Service\DependencyOrderingService;
use TYPO3\CMS\Core\Type\Bitmask\Permission;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\StringUtility;
/**
* New Content element wizard. This is the modal that pops up when clicking "+content" in page module, which
* will trigger wizardAction() since there is a colPos given. Method positionMapAction() is triggered for
* instance from the records module "+content" on tt_content table header, and from records module doc-header "+"
* and then "Click here for wizard".
*
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
class NewContentElementController
{
protected int $id = 0;
protected int $uid_pid = 0;
protected array $pageInfo = [];
protected int $sys_language = 0;
protected string $returnUrl = '';
/**
* If set, the content is destined for a specific column.
*/
protected ?int $colPos = null;
public function __construct(
protected readonly UriBuilder $uriBuilder,
protected readonly BackendViewFactory $backendViewFactory,
protected readonly EventDispatcherInterface $eventDispatcher,
protected readonly DependencyOrderingService $dependencyOrderingService,
protected readonly TcaSchemaFactory $tcaSchemaFactory,
protected readonly BackendLayoutView $backendLayoutView,
) {}
/**
* Process incoming request and dispatch to the requested action
*/
public function handleRequest(ServerRequestInterface $request): ResponseInterface
{
$parsedBody = $request->getParsedBody();
$queryParams = $request->getQueryParams();
// Setting internal vars:
$this->id = (int)($parsedBody['id'] ?? $queryParams['id'] ?? 0);
$this->sys_language = (int)($parsedBody['language_tag'] ?? $queryParams['language_tag'] ?? 0);
$this->returnUrl = GeneralUtility::sanitizeLocalUrl($parsedBody['returnUrl'] ?? $queryParams['returnUrl'] ?? '', $request);
$colPos = $parsedBody['colPos'] ?? $queryParams['colPos'] ?? null;
$this->colPos = $colPos === null ? null : (int)$colPos;
$this->uid_pid = (int)($parsedBody['uid_pid'] ?? $queryParams['uid_pid'] ?? 0);
// Getting the current page and receiving access information
$this->pageInfo = BackendUtility::readPageAccess($this->id, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW)) ?: [];
$action = (string)($parsedBody['action'] ?? $queryParams['action'] ?? 'wizard');
if ($action === 'wizard') {
return $this->wizardAction($request);
}
if ($action === 'positionMap') {
return $this->positionMapAction($request);
}
return new HtmlResponse('Action not allowed', 400);
}
/**
* Renders the wizard
*/
protected function wizardAction(ServerRequestInterface $request): ResponseInterface
{
if (!$this->id || $this->pageInfo === []) {
// No pageId or no access.
return new HtmlResponse('No Access');
}
// Whether position selection must be performed (no colPos was yet defined)
$positionSelection = $this->colPos === null;
// Get processed and modified wizard items
$wizardItems = $this->eventDispatcher->dispatch(
new ModifyNewContentElementWizardItemsEvent(
$this->getWizards($request),
$this->pageInfo,
$this->colPos,
$this->sys_language,
$this->uid_pid,
$request,
)
)->getWizardItems();
$key = 'common';
$categories = [];
foreach ($wizardItems as $wizardKey => $wizardItem) {
// An item is either a header or an item rendered with title/description and icon:
if (isset($wizardItem['header'])) {
$key = $wizardKey;
$categories[$key] = [
'identifier' => $key,
'label' => $wizardItem['header'] ?: '-',
'items' => [],
];
} else {
// Get default values for the wizard item
$defaultValues = (array)($wizardItem['defaultValues'] ?? []);
// Initialize the view variables for the item
$item = [
'identifier' => $wizardKey,
'icon' => $wizardItem['iconIdentifier'] ?? '',
'iconOverlay' => $wizardItem['iconOverlay'] ?? '',
'label' => $wizardItem['title'] ?? '',
'description' => $wizardItem['description'] ?? '',
'defaultValues' => $defaultValues,
];
// If the URL was already created (e.g. via the PSR-14 event) this needs to be
// kept and not overwritten
if (isset($wizardItem['url'])) {
$item['url'] = $wizardItem['url'];
if ($positionSelection) {
$item['requestType'] = 'ajax';
$item['saveAndClose'] = (bool)($wizardItem['saveAndClose'] ?? false);
}
} elseif ($positionSelection) {
$item['url'] = (string)$this->uriBuilder
->buildUriFromRoute(
'new_content_element_wizard',
[
'action' => 'positionMap',
'id' => $this->id,
'sys_language_uid' => $this->sys_language,
'returnUrl' => $this->returnUrl,
]
);
$item['requestType'] = 'ajax';
$item['saveAndClose'] = (bool)($wizardItem['saveAndClose'] ?? false);
} else {
// In case no position has to be selected, we can just add the target
if ($wizardItem['saveAndClose'] ?? false) {
// Go to DataHandler directly instead of FormEngine
$item['url'] = (string)$this->uriBuilder->buildUriFromRoute('tce_db', [
'data' => [
'tt_content' => [
StringUtility::getUniqueId('NEW') => array_replace($defaultValues, [
'colPos' => $this->colPos,
'pid' => $this->uid_pid,
'sys_language_uid' => $this->sys_language,
]),
],
],
'redirect' => $this->returnUrl,
]);
} else {
$item['url'] = (string)$this->uriBuilder->buildUriFromRoute('record_edit', [
'edit' => [
'tt_content' => [
$this->uid_pid => 'new',
],
],
'module' => '_CURRENT_MODULE_',
'returnUrl' => $this->returnUrl,
'defVals' => [
'tt_content' => array_replace($defaultValues, [
'colPos' => $this->colPos,
'sys_language_uid' => $this->sys_language,
]),
],
]);
}
}
$categories[$key]['items'][] = $item;
}
}
// Unset empty categories
foreach ($categories as $key => $category) {
if ($category['items'] === []) {
unset($categories[$key]);
}
}
$view = $this->backendViewFactory->create($request);
$view->assignMultiple([
'positionSelection' => $positionSelection,
'categoriesJson' => GeneralUtility::jsonEncodeForHtmlAttribute($categories, false),
]);
return new HtmlResponse($view->render('NewContentElement/Wizard'));
}
/**
* Renders the position map
*/
protected function positionMapAction(ServerRequestInterface $request): ResponseInterface
{
$pageInfo = BackendUtility::readPageAccess($this->id, $this->getBackendUser()->getPagePermsClause(Permission::PAGE_SHOW));
$posMap = GeneralUtility::makeInstance(ContentCreationPagePositionMap::class);
$posMap->cur_sys_language = $this->sys_language;
$posMap->defVals = (array)($request->getParsedBody()['defVals'] ?? []);
$posMap->saveAndClose = (bool)($request->getParsedBody()['saveAndClose'] ?? false);
$posMap->R_URI = $this->returnUrl;
$view = $this->backendViewFactory->create($request);
$view->assign('posMap', $posMap->printContentElementColumns($this->id, $pageInfo, $request));
return new HtmlResponse($view->render('NewContentElement/PositionMap'));
}
/**
* Returns the array of elements in the wizard display.
* For the plugin section there is support for adding elements there from a global variable.
*/
protected function getWizards(ServerRequestInterface $request): array
{
$wizards = $this->loadAvailableWizards();
$newContentElementWizardTsConfig = BackendUtility::getPagesTSconfig($this->id)['mod.']['wizards.']['newContentElement.'] ?? [];
$wizardsFromPageTSConfig = $this->migrateCommonGroupToDefault($newContentElementWizardTsConfig['wizardItems.'] ?? []);
$wizardsFromPageTSConfig = $this->migratePositionalCommonGroupToDefault($wizardsFromPageTSConfig);
$wizards = $this->mergeContentElementWizardsWithPageTSConfigWizards($wizards, $wizardsFromPageTSConfig);
$wizards = $this->removeWizardsByPageTs($wizards, $newContentElementWizardTsConfig);
$wizards = $this->removeWizardsByBackendLayoutColPosRestriction($wizards, $this->pageInfo, $this->colPos, $request);
if ($wizards === []) {
return [];
}
$wizardItems = [];
foreach ($wizards as $groupKey => $wizardGroup) {
$wizards[$groupKey] = $this->prepareDependencyOrdering($wizards[$groupKey], 'before');
$wizards[$groupKey] = $this->prepareDependencyOrdering($wizards[$groupKey], 'after');
}
$orderedWizards = $this->orderWizards($wizards);
foreach ($orderedWizards as $groupKey => $wizardGroup) {
$groupKey = rtrim($groupKey, '.');
$groupItems = [];
$wizardElements = $wizardGroup['elements.'] ?? [];
if (is_array($wizardElements)) {
$wizardElements = $this->orderElements($wizardElements);
foreach ($wizardElements as $itemKey => $itemConf) {
$itemKey = rtrim($itemKey, '.');
if ($itemConf !== []) {
$groupItems[$groupKey . '_' . $itemKey] = $this->prepareWizardItem($itemConf);
}
}
}
if (!empty($groupItems)) {
$wizardItems[$groupKey]['header'] = $this->getLanguageService()->sL($wizardGroup['header'] ?? '');
$wizardItems = array_merge($wizardItems, $groupItems);
}
}
// Remove elements where preset values are not allowed:
return $this->removeInvalidWizardItems($wizardItems);
}
protected function loadAvailableWizards(): array
{
$schema = $this->tcaSchemaFactory->get('tt_content');
// Foreign table support for TypeInformation is not supported in tt_content
$typeField = $schema->getSubSchemaTypeInformation()->getFieldName();
$fieldConfig = $schema->hasField($typeField) ? $schema->getField($typeField)->getConfiguration() : [];
$items = $fieldConfig['items'] ?? [];
$itemGroups = $fieldConfig['itemGroups'] ?? [];
$groupedWizardItems = [];
foreach (array_keys($itemGroups) as $groupIdentifier) {
$groupedWizardItems[$groupIdentifier . '.']['header'] = $itemGroups[$groupIdentifier];
}
foreach ($items as $item) {
$selectItem = SelectItem::fromTcaItemArray($item);
if ($selectItem->isDivider()) {
continue;
}
$recordType = $selectItem->getValue();
$groupIdentifier = $selectItem->getGroup();
$groupedWizardItems[$groupIdentifier . '.']['elements.'] ??= [];
// In case this group is not defined in itemGroups, use the group identifier as label.
$groupedWizardItems[$groupIdentifier . '.']['header'] ??= $groupIdentifier;
$itemDescription = $selectItem->getDescription();
$wizardEntry = [
'iconIdentifier' => $selectItem->getIcon(),
'iconOverlay' => $selectItem->getIconOverlay(),
'title' => $selectItem->getLabel(),
'description' => $itemDescription['description'] ?? ($itemDescription ?? ''),
'defaultValues' => [
'CType' => $recordType,
],
];
if ($schema->hasSubSchema($recordType)) {
$wizardEntry = array_replace_recursive($wizardEntry, $schema->getSubSchema($recordType)->getRawConfiguration()['creationOptions'] ?? []);
}
$groupedWizardItems[$groupIdentifier . '.']['elements.'][$recordType . '.'] = $wizardEntry;
}
return $groupedWizardItems;
}
/**
* This method merges Content Element wizards defined by TCA with wizards defined in PageTSConfig.
* PageTS has precedence.
* It might happen that both TCA and PageTS define an entry with exactly the same default values.
* In such a case, the automatically added TCA entry is dropped.
*/
protected function mergeContentElementWizardsWithPageTSConfigWizards(array $contentElementWizards, array $pageTsConfigWizards): array
{
$uniqueDefaultValuesInPageTsWizards = [];
foreach ($pageTsConfigWizards as $wizard) {
foreach ($wizard['elements.'] ?? [] as $elementConfig) {
$defaultValues = $elementConfig['tt_content_defValues.'] ?? [];
if ($defaultValues === []) {
continue;
}
ksort($defaultValues);
$uniqueDefaultValuesInPageTsWizards[] = $defaultValues;
}
}
foreach ($contentElementWizards as $group => $wizard) {
foreach ($wizard['elements.'] ?? [] as $key => $elementConfig) {
// Remove duplicated entry.
$defaultValues = $elementConfig['defaultValues'];
ksort($defaultValues);
if (in_array($defaultValues, $uniqueDefaultValuesInPageTsWizards, true)) {
unset($contentElementWizards[$group]['elements.'][$key]);
}
}
}
$mergedWizards = array_replace_recursive($contentElementWizards, $pageTsConfigWizards);
return $mergedWizards;
}
/**
* Orders elements within a wizard group using before/after configuration.
* Similar to orderWizards() but for individual content elements.
*/
protected function orderElements(array $elements): array
{
// Check if any element has before/after configuration
// and return early if no reordering is required.
if (!$this->hasPositionalArguments($elements)) {
return $elements;
}
// Prepare elements for dependency ordering.
// Create implicit chain based on initial order for consecutive elements
// without explicit dependencies, preserving relative order while allowing
// explicit positioning.
$preparedElements = [];
// First pass: prepare all elements with their explicit dependencies
foreach ($elements as $elementKey => $element) {
$preparedElement = $element;
// Prepare before/after values (they might be comma-separated strings)
$preparedElement = $this->prepareDependencyOrdering($preparedElement, 'before');
$preparedElement = $this->prepareDependencyOrdering($preparedElement, 'after');
$preparedElements[$elementKey] = $preparedElement;
}
// Second pass: add implicit chain for consecutive elements without explicit dependencies
// This preserves relative order within blocks, while explicit dependencies can reorder them
$previousIndependentElementKey = null;
foreach ($elements as $elementKey => $element) {
$isIndependent = empty($element['before']) && empty($element['after']);
if ($isIndependent) {
// Element without explicit dependency: chain with previous independent element
if ($previousIndependentElementKey !== null) {
$existingAfter = $preparedElements[$elementKey]['after'] ?? [];
if (!in_array($previousIndependentElementKey, $existingAfter, true)) {
$preparedElements[$elementKey]['after'] = array_merge($existingAfter, [$previousIndependentElementKey]);
}
}
$previousIndependentElementKey = $elementKey;
}
}
// Use dependency ordering service to order elements
return $this->dependencyOrderingService->orderByDependencies($preparedElements);
}
protected function hasPositionalArguments(array $elements): bool
{
foreach ($elements as $element) {
if (!empty($element['before']) || !empty($element['after'])) {
return true;
}
}
return false;
}
/**
* There are two separate ordering systems for wizard groups:
* 1. TCA itemGroup sorting by associative array item order.
* 2. PageTS defined order by "before" and "after".
*
* System 1. has a well-defined order, where every item defines "after" (linked list).
* Due to this, the two system cannot be combined.
* As soon as system 2 defines at least one "before" or "after" it takes over.
*/
protected function orderWizards(array $wizards): array
{
// First round: Order by TCA defined sorting.
$hasAtLeastOnePositionalArgument = false;
foreach ($wizards as $group => $wizard) {
if (isset($wizard['before'])) {
$hasAtLeastOnePositionalArgument = true;
$wizards[$group]['pageTsBefore'] = $wizard['before'];
unset($wizards[$group]['before']);
}
if (isset($wizard['after'])) {
$hasAtLeastOnePositionalArgument = true;
$wizards[$group]['pageTsAfter'] = $wizard['after'];
unset($wizards[$group]['after']);
}
}
// No order defined by pageTS. Use TCA sorting.
if (!$hasAtLeastOnePositionalArgument) {
$schema = $this->tcaSchemaFactory->get('tt_content');
// Foreign table support for TypeInformation is not supported in tt_content
$typeField = $schema->getSubSchemaTypeInformation()->getFieldName();
$fieldConfig = $schema->hasField($typeField) ? $schema->getField($typeField)->getConfiguration() : [];
$itemGroups = $fieldConfig['itemGroups'] ?? [];
// Auto-set positional information based on TCA itemGroups sorting.
$lastGroup = null;
foreach (array_keys($itemGroups) as $groupIdentifier) {
if (!array_key_exists($groupIdentifier . '.', $wizards)) {
continue;
}
if ($lastGroup !== null) {
$wizards[$groupIdentifier . '.']['after'] = [$lastGroup . '.'];
}
$lastGroup = $groupIdentifier;
}
return $this->dependencyOrderingService->orderByDependencies($wizards);
}
// Override order by pageTsConfig.
foreach ($wizards as $group => $wizard) {
// Unset "after" previously set by Content Element wizards.
unset($wizards[$group]['after']);
if (isset($wizard['pageTsBefore'])) {
$wizards[$group]['before'] = $wizard['pageTsBefore'];
unset($wizards[$group]['pageTsBefore']);
}
if (isset($wizard['pageTsAfter'])) {
$wizards[$group]['after'] = $wizard['pageTsAfter'];
unset($wizards[$group]['pageTsAfter']);
}
}
return $this->dependencyOrderingService->orderByDependencies($wizards);
}
/**
* This method returns the wizard items, defined in Page TSconfig for b/w
* compatibility.
*
* Additionally, it migrates previously defined wizard items in the
* `common` group to the new `default` group, which is defined in TCA.
*
* @param array<string, array> $wizardsFromPageTs
* @return array<string, array>
*/
protected function migrateCommonGroupToDefault(array $wizardsFromPageTs): array
{
if (!array_key_exists('common.', $wizardsFromPageTs)) {
// In case "common." is not defined, just return the wizards, which are still defined via Page TSconfig
return $wizardsFromPageTs;
}
// Prepare "removeItems" to be merged
if ($wizardsFromPageTs['default.']['elements.']['removeItems'] ?? false) {
$wizardsFromPageTs['default.']['removeItems'] = GeneralUtility::trimExplode(',', $wizardsFromPageTs['default.']['elements.']['removeItems'] ?? '', true);
} elseif ($wizardsFromPageTs['default.']['removeItems'] ?? false) {
$wizardsFromPageTs['default.']['removeItems'] = GeneralUtility::trimExplode(',', $wizardsFromPageTs['default.']['removeItems'], true);
}
if ($wizardsFromPageTs['common.']['elements.']['removeItems'] ?? false) {
$wizardsFromPageTs['common.']['removeItems'] = GeneralUtility::trimExplode(',', $wizardsFromPageTs['common.']['elements.']['removeItems'] ?? '', true);
} elseif ($wizardsFromPageTs['common.']['removeItems'] ?? false) {
$wizardsFromPageTs['common.']['removeItems'] = GeneralUtility::trimExplode(',', $wizardsFromPageTs['common.']['removeItems'], true);
}
$defaultItems = array_merge_recursive($wizardsFromPageTs['default.'] ?? [], $wizardsFromPageTs['common.']);
unset($wizardsFromPageTs['common.']);
if ($defaultItems !== []) {
$wizardsFromPageTs['default.'] = $defaultItems;
}
return $wizardsFromPageTs;
}
protected function migratePositionalCommonGroupToDefault(array $wizards): array
{
foreach ($wizards as $group => $wizard) {
if (($wizard['before'] ?? '') === 'common') {
$wizards[$group]['before'] = 'default';
}
if (($wizard['after'] ?? '') === 'common') {
$wizards[$group]['after'] = 'default';
}
}
return $wizards;
}
protected function prepareWizardItem(array $itemConf): array
{
// Just replace the "known" keys of $itemConf. This way extensions are able to set custom keys, which are not
// used by the controller, but might be evaluated by listeners of the ModifyNewContentElementWizardItemsEvent.
$itemConf = array_replace_recursive(
$itemConf,
[
'title' => trim($this->getLanguageService()->sL($itemConf['title'] ?? '')),
'description' => trim($this->getLanguageService()->sL($itemConf['description'] ?? '')),
'iconIdentifier' => $itemConf['iconIdentifier'] ?? null,
'saveAndClose' => (bool)($itemConf['saveAndClose'] ?? false),
'defaultValues' => array_replace_recursive(
$itemConf['tt_content_defValues'] ?? [],
$itemConf['tt_content_defValues.'] ?? [],
$itemConf['defaultValues'] ?? []
),
]
);
unset($itemConf['tt_content_defValues'], $itemConf['tt_content_defValues.']);
return $itemConf;
}
protected function removeWizardsByPageTs(array $wizards, mixed $wizardsItemsPageTs): array
{
$removeWizardItems = $wizardsItemsPageTs['wizardItems.']['removeItems'] ?? [];
if (is_string($removeWizardItems)) {
$removeWizardItems = GeneralUtility::trimExplode(',', $removeWizardItems, true);
}
foreach ($wizards as $key => &$wizard) {
// Leave out removeItems etc.
if (is_string($wizard)) {
unset($wizards[$key]);
continue;
}
if (in_array(rtrim((string)$key, '.'), $removeWizardItems, true)) {
unset($wizards[$key]);
continue;
}
$removeWizardElements = $wizardsItemsPageTs['wizardItems.'][$key]['removeItems'] ?? [];
if (is_string($removeWizardElements)) {
$removeWizardElements = GeneralUtility::trimExplode(',', $removeWizardElements, true);
}
foreach ($wizard['elements.'] ?? [] as $identifier => $element) {
if (in_array(rtrim((string)$identifier, '.'), $removeWizardElements, true)) {
unset($wizard['elements.'][$identifier]);
}
}
}
return $wizards;
}
protected function removeWizardsByBackendLayoutColPosRestriction(array $wizardGroups, array $pageInfo, ?int $colPos, ServerRequestInterface $request): array
{
// Force colPos to 0 if null to apply restrictions for 0 by default.
$colPos = (int)$colPos;
// This is the page uid of a workspace overlay already so backend layouts of workspace
// changed or moved pages should be considered correctly.
$pid = (int)$pageInfo['uid'];
$backendLayout = $this->backendLayoutView->getBackendLayoutForPage($pid);
$columnConfiguration = $this->backendLayoutView->getColPosConfigurationForPage($backendLayout, $colPos, $pid, $request);
if (!empty($columnConfiguration['allowedContentTypes'])) {
$allowedContentTypes = GeneralUtility::trimExplode(',', $columnConfiguration['allowedContentTypes'], true);
foreach ($wizardGroups as $wizardGroupName => $wizards) {
foreach (($wizards['elements.'] ?? []) as $wizardKey => $wizard) {
$cType = $wizard['defaultValues']['CType'] ?? $wizard['tt_content_defValues.']['CType'] ?? '';
if (empty($cType)) {
continue;
}
if (!in_array(trim($cType), $allowedContentTypes, true)) {
unset($wizardGroups[$wizardGroupName]['elements.'][$wizardKey]);
}
}
}
}
if (!empty($columnConfiguration['disallowedContentTypes'])) {
$disAllowedContentTypes = GeneralUtility::trimExplode(',', $columnConfiguration['disallowedContentTypes'], true);
foreach ($wizardGroups as $wizardGroupName => $wizards) {
foreach (($wizards['elements.'] ?? []) as $wizardKey => $wizard) {
$cType = $wizard['defaultValues']['CType'] ?? $wizard['tt_content_defValues.']['CType'] ?? '';
if (empty($cType)) {
continue;
}
if (in_array(trim($cType), $disAllowedContentTypes, true)) {
unset($wizardGroups[$wizardGroupName]['elements.'][$wizardKey]);
}
}
}
}
return $wizardGroups;
}
/**
* Checks the array for elements which might contain invalid default values and will unset them!
* Looks for the "defaultValues" key in each element and if found it will traverse that array
* as fieldname / value pairs and check.
*/
protected function removeInvalidWizardItems(array $wizardItems): array
{
$schema = $this->tcaSchemaFactory->get('tt_content');
$removeItems = [];
$keepItems = [];
// Get TCEFORM from TSconfig of current page
$TCEFORM_TSconfig = FormEngineUtility::getTCEFORM_TSconfig('tt_content', ['pid' => $this->id]);
$backendUser = $this->getBackendUser();
// Traverse wizard items:
foreach ($wizardItems as $key => $cfg) {
if (!is_array($cfg['defaultValues'] ?? false)) {
continue;
}
// This is not a group; this is likely broken configuration
if ($cfg['defaultValues'] === []) {
unset($wizardItems[$key]);
}
// If defaultValues are defined, check access by traversing all fields with default values:
foreach ($cfg['defaultValues'] as $fieldName => $value) {
if (!$schema->hasField($fieldName)) {
continue;
}
// Get information about if the field value is OK:
$config = $schema->getField($fieldName)->getConfiguration();
$userNotAllowedToAccess = ($config['type'] ?? '') === 'select' && ($config['authMode'] ?? false)
&& !$backendUser->checkAuthMode('tt_content', $fieldName, $value);
// Check removeItems
if (!isset($removeItems[$fieldName]) && ($TCEFORM_TSconfig[$fieldName]['removeItems'] ?? false)) {
$removeItems[$fieldName] = array_flip(GeneralUtility::trimExplode(
',',
$TCEFORM_TSconfig[$fieldName]['removeItems'],
true
));
}
// Check keepItems
if (!isset($keepItems[$fieldName]) && ($TCEFORM_TSconfig[$fieldName]['keepItems'] ?? false)) {
$keepItems[$fieldName] = array_flip(GeneralUtility::trimExplode(
',',
$TCEFORM_TSconfig[$fieldName]['keepItems'],
true
));
}
$isNotInKeepItems = !empty($keepItems[$fieldName]) && !isset($keepItems[$fieldName][$value]);
if ($userNotAllowedToAccess || ($fieldName === 'CType' && (isset($removeItems[$fieldName][$value]) || $isNotInKeepItems))) {
// Remove element all together:
unset($wizardItems[$key]);
break;
}
// Add the parameter:
$wizardItems[$key]['defaultValues'][$fieldName] = $this->getLanguageService()->sL($value);
}
}
return $wizardItems;
}
/**
* Prepare a wizard tab configuration for sorting.
*/
protected function prepareDependencyOrdering(array $wizardGroup, string $key): array
{
if (is_string($wizardGroup[$key] ?? null)) {
$wizardGroup[$key] = GeneralUtility::trimExplode(',', $wizardGroup[$key], true);
}
if (is_array($wizardGroup[$key] ?? null)) {
$wizardGroup[$key] = array_map(
static fn(string $s): string => rtrim($s, '.') . '.',
$wizardGroup[$key]
);
}
return $wizardGroup;
}
protected function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,78 @@
<?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\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Clipboard\Clipboard;
use TYPO3\CMS\Backend\ContextMenu\ContextMenu;
use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Utility\GeneralUtility;
/**
* Script Class for the Context Sensitive Menu in TYPO3
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
class ContextMenuController
{
/**
* Renders a context menu
*/
public function getContextMenuAction(ServerRequestInterface $request): ResponseInterface
{
$contextMenu = GeneralUtility::makeInstance(ContextMenu::class);
$params = $request->getQueryParams();
$table = $params['table'] ?? '';
$identifier = $params['uid'] ?? '';
$context = $params['context'] ?? '';
if ($table === '' || $identifier === '') {
return new JsonResponse([], 400);
}
$items = $contextMenu->getItems($table, $identifier, $context);
return new JsonResponse($items);
}
public function clipboardAction(ServerRequestInterface $request): ResponseInterface
{
$clipboard = GeneralUtility::makeInstance(Clipboard::class);
$clipboard->initializeClipboard($request);
$clipboard->lockToNormal();
$queryParams = $request->getQueryParams();
$parsedBody = $request->getParsedBody();
$clipboardCommand = array_replace_recursive($queryParams['CB'] ?? [], $parsedBody['CB'] ?? []);
// URL-decoded keys are required for the clipboard to recognize file identifiers (e.g. _FILE|...)
if (isset($clipboardCommand['el']) && is_array($clipboardCommand['el'])) {
$decodedElements = [];
foreach ($clipboardCommand['el'] as $key => $value) {
$decodedElements[urldecode((string)$key)] = $value;
}
$clipboardCommand['el'] = $decodedElements;
}
$clipboard->setCmd($clipboardCommand);
$clipboard->cleanCurrent();
$clipboard->endClipboard();
return new JsonResponse([]);
}
}
@@ -0,0 +1,453 @@
<?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\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\UriInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Dto\FormElementData;
use TYPO3\CMS\Backend\Form\Exception\AccessDeniedException;
use TYPO3\CMS\Backend\Form\Exception\DatabaseRecordException;
use TYPO3\CMS\Backend\Form\Exception\DatabaseRecordWorkspaceDeletePlaceholderException;
use TYPO3\CMS\Backend\Form\Exception\NoFieldsToRenderException;
use TYPO3\CMS\Backend\Form\FormAction;
use TYPO3\CMS\Backend\Form\FormDataCompiler;
use TYPO3\CMS\Backend\Form\FormDataGroup\TcaDatabaseRecord;
use TYPO3\CMS\Backend\Form\FormResultCollection;
use TYPO3\CMS\Backend\Form\FormResultFactory;
use TYPO3\CMS\Backend\Form\FormResultHandler;
use TYPO3\CMS\Backend\Form\NodeFactory;
use TYPO3\CMS\Backend\Module\ModuleProvider;
use TYPO3\CMS\Backend\Routing\UriBuilder;
use TYPO3\CMS\Backend\Template\ModuleTemplate;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
use TYPO3\CMS\Backend\Utility\BackendUtility;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\DataHandling\DataHandler;
use TYPO3\CMS\Core\Http\RedirectResponse;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Imaging\IconSize;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Schema\TcaSchemaFactory;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Versioning\VersionState;
/**
* Lightweight controller for editing a single existing record inside the context panel.
*
* This is a focused alternative to EditDocumentController that only handles editing
* one existing record. It renders the same FormEngine form but with a minimal contextual
* template, communicating save/close signals to the parent frame via JavaScript.
*
* @internal This controller is not part of the TYPO3 public API.
*/
#[AsController]
readonly class ContextualRecordEditController
{
public function __construct(
private PageRenderer $pageRenderer,
private UriBuilder $uriBuilder,
private ModuleTemplateFactory $moduleTemplateFactory,
private ModuleProvider $moduleProvider,
private FormDataCompiler $formDataCompiler,
private NodeFactory $nodeFactory,
private FormResultFactory $formResultFactory,
private FormResultHandler $formResultHandler,
private TcaSchemaFactory $tcaSchemaFactory,
private IconFactory $iconFactory,
) {}
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
return $request->getMethod() === 'POST' ? $this->persistAction($request) : $this->renderAction($request);
}
/**
* Handle POST: process save/close via DataHandler and redirect back
*/
private function persistAction(ServerRequestInterface $request): ResponseInterface
{
$queryParams = $request->getQueryParams();
$editConf = $this->parseAndValidateEditConf($queryParams['edit'] ?? []);
$table = $editConf['table'];
$uid = $this->resolveOverlayUid($table, $editConf['uid']);
$requestAction = FormAction::createFromRequest($request);
// Handle close (without save)
if ($requestAction->shouldHandleDocumentClosing()) {
return $this->redirectToSelf($queryParams, ['closed' => '1']);
}
$saveSucceeded = false;
if ($requestAction->shouldProcessData()) {
$saveSucceeded = $this->processData($request, $table, $uid);
if ($saveSucceeded && $requestAction->shouldCloseAfterSave()) {
return $this->redirectToSelf($queryParams, ['closed' => '1', 'justSaved' => '1']);
}
}
// POST-redirect-GET
$flags = ['edit' => [$table => [$uid => 'edit']]];
if ($saveSucceeded) {
$flags['justSaved'] = '1';
}
return $this->redirectToSelf($queryParams, $flags);
}
/**
* Handle GET: compile the FormEngine form and render the contextual edit template.
*/
private function renderAction(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$view->setUiBlock(true);
$queryParams = $request->getQueryParams();
$editConf = $this->parseAndValidateEditConf($queryParams['edit'] ?? []);
$table = $editConf['table'];
$uid = $this->resolveOverlayUid($table, $editConf['uid']);
$returnUrl = GeneralUtility::sanitizeLocalUrl($queryParams['returnUrl'] ?? '', $request);
$overrideVals = is_array($queryParams['overrideVals'] ?? false) ? $queryParams['overrideVals'] : [];
$columnsOnly = $this->prepareColumnsOnlyConfiguration($queryParams['columnsOnly'] ?? null, $table);
$module = $this->moduleProvider->getModule((string)($queryParams['module'] ?? ''), $this->getBackendUser());
if ($module !== null) {
$view->setModuleName($module->getIdentifier());
}
// Compile FormEngine form
$currentEditingUrl = $this->uriBuilder->buildUriFromRoute('record_edit_contextual', array_merge($queryParams, [
'edit' => [$table => [$uid => 'edit']],
'returnUrl' => $returnUrl,
]));
$formResult = $this->compileForm($request, $view, $table, $uid, $overrideVals, $columnsOnly, $currentEditingUrl);
$firstEl = $formResult['element'] ?? null;
if ($firstEl !== null) {
$this->formResultHandler->addAssets($formResult['results']);
$body = '
<form action="' . htmlspecialchars((string)$currentEditingUrl) . '" method="post" enctype="multipart/form-data" name="editform" id="ContextualRecordEditController">
' . $formResult['results']->getHtml() . '
<input type="hidden" name="returnUrl" value="' . htmlspecialchars($returnUrl) . '" />
<input type="hidden" name="closeDoc" value="0" />
</form>';
} else {
$view->setUiBlock(false);
$body = $formResult['errorHtml'] ?? $this->getInfobox(
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:noEditForm.message'),
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:noEditForm'),
);
}
$recordTitle = $firstEl !== null && trim($firstEl->title) !== ''
? $firstEl->title
: '[' . $this->getLanguageService()->sL('core.core:labels.no_title') . ']';
$recordTitle = BackendUtility::cropToTitleLength($recordTitle);
// Contextual JS module with options
$contextualOptions = [];
if ($queryParams['justSaved'] ?? false) {
$contextualOptions['justSaved'] = true;
$contextualOptions['savedRecordTitle'] = $recordTitle;
}
if ($queryParams['closed'] ?? false) {
$contextualOptions['closed'] = true;
}
$this->pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::create('@typo3/backend/contextual-record-edit.js')->instance($contextualOptions)
);
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/context-menu.js');
$this->pageRenderer->loadJavaScriptModule('@typo3/backend/localization.js');
// Template variables
$view->assign('bodyHtml', $body);
$view->assign('recordTitle', $recordTitle);
// Full edit URL points to the standard EditDocumentController
$fullEditParams = [
'edit' => [$table => [$uid => 'edit']],
'returnUrl' => $returnUrl,
];
if ($module !== null) {
$fullEditParams['module'] = $module->getIdentifier();
}
$view->assign('fullEditUrl', (string)$this->uriBuilder->buildUriFromRoute('record_edit', $fullEditParams));
return $view->renderResponse('Form/ContextualRecordEdit');
}
/**
* Parse and validate the edit configuration. Ensures exactly one record with command "edit".
*
* @return array{table: string, uid: int}
*/
private function parseAndValidateEditConf(array|string $editConf): array
{
if (!is_array($editConf)) {
throw new \InvalidArgumentException('Invalid edit configuration', 1772580316);
}
$backendUser = $this->getBackendUser();
foreach ($editConf as $table => $conf) {
if (!is_array($conf) || !$this->tcaSchemaFactory->has($table)) {
continue;
}
if (!$backendUser->check('tables_modify', $table)) {
continue;
}
foreach ($conf as $uidList => $command) {
if ($command !== 'edit') {
continue;
}
$uid = (int)$uidList;
if ($uid > 0) {
return ['table' => $table, 'uid' => $uid];
}
}
}
throw new \InvalidArgumentException('ContextualRecordEditController requires exactly one existing record to edit', 1772580317);
}
private function prepareColumnsOnlyConfiguration(mixed $columnsOnly, string $table): array
{
if (!is_array($columnsOnly) || $columnsOnly === []) {
return [];
}
$finalColumnsOnly = array_map(
static fn($fields) => is_array($fields) ? $fields : GeneralUtility::trimExplode(',', $fields, true),
$columnsOnly
);
// Add slug generator fields as hidden fields
if (!empty($finalColumnsOnly[$table]) && $this->tcaSchemaFactory->has($table)) {
$schema = $this->tcaSchemaFactory->get($table);
foreach ($finalColumnsOnly[$table] as $fieldName) {
if (!$schema->hasField($fieldName)) {
continue;
}
$field = $schema->getField($fieldName);
$postModifiers = $field->getConfiguration()['generatorOptions']['postModifiers'] ?? [];
if ($field->isType(\TYPO3\CMS\Core\DataHandling\TableColumnType::SLUG)
&& (!is_array($postModifiers) || $postModifiers === [])
) {
$fieldGroups = $field->getConfiguration()['generatorOptions']['fields'] ?? [];
if (is_string($fieldGroups)) {
$fieldGroups = [$fieldGroups];
}
foreach ($fieldGroups as $fields) {
$finalColumnsOnly['__hiddenGeneratorFields'][$table] = array_merge(
$finalColumnsOnly['__hiddenGeneratorFields'][$table] ?? [],
(is_array($fields) ? $fields : GeneralUtility::trimExplode(',', $fields, true))
);
}
}
}
if (!empty($finalColumnsOnly['__hiddenGeneratorFields'][$table])) {
$finalColumnsOnly['__hiddenGeneratorFields'][$table] = array_diff(
array_unique($finalColumnsOnly['__hiddenGeneratorFields'][$table]),
$finalColumnsOnly[$table]
);
}
}
return $finalColumnsOnly;
}
/**
* Process save data via DataHandler.
*
* @return bool True if at least one record was saved without errors
*/
private function processData(ServerRequestInterface $request, string $table, int $uid): bool
{
$parsedBody = $request->getParsedBody();
$dataMap = $parsedBody['data'] ?? [];
$dataHandler = GeneralUtility::makeInstance(DataHandler::class);
$dataHandler->setControl($parsedBody['control'] ?? []);
$dataHandler->start($dataMap, $parsedBody['cmd'] ?? []);
if (is_array($parsedBody['mirror'] ?? null)) {
$dataHandler->setMirror($parsedBody['mirror']);
}
$dataHandler->process_datamap();
$dataHandler->process_cmdmap();
// Check if save succeeded (no errors for this record)
$erroneousRecords = $dataHandler->printLogErrorMessages();
return !in_array($table . '.' . $uid, $erroneousRecords, true) && isset($dataMap[$table][$uid]);
}
/**
* @return array{element: FormElementData, results: FormResultCollection}|array{errorHtml: string}
*/
private function compileForm(
ServerRequestInterface $request,
ModuleTemplate $view,
string $table,
int $uid,
array $overrideVals,
array $columnsOnly,
UriInterface $currentEditingUrl,
): array {
try {
$formDataCompilerInput = [
'request' => $request,
'tableName' => $table,
'vanillaUid' => $uid,
'command' => 'edit',
'returnUrl' => (string)$currentEditingUrl,
];
if ($overrideVals !== [] && is_array($overrideVals[$table] ?? null)) {
$formDataCompilerInput['overrideValues'] = $overrideVals[$table];
}
$formData = $this->formDataCompiler->compile($formDataCompilerInput, GeneralUtility::makeInstance(TcaDatabaseRecord::class));
// Display "is-locked" message
$lockInfo = BackendUtility::isRecordLocked($table, $formData['databaseRow']['uid']);
if ($lockInfo) {
$view->addFlashMessage($lockInfo['msg'], '', \TYPO3\CMS\Core\Type\ContextualFeedbackSeverity::WARNING);
}
$formElementData = new FormElementData(
title: $formData['recordTitle'],
table: $table,
uid: $formData['databaseRow']['uid'],
pid: $formData['databaseRow']['pid'] ?? 0,
record: $formData['databaseRow'],
viewId: 0,
command: 'edit',
userPermissionOnPage: $formData['userPermissionOnPage'],
);
BackendUtility::lockRecords($table, $formElementData->uid, $table === 'tt_content' ? $formElementData->pid : 0);
if (!empty($columnsOnly[$table])) {
$formData['fieldListToRender'] = implode(',', $columnsOnly[$table]);
if (!empty($columnsOnly['__hiddenGeneratorFields'][$table])) {
$formData['hiddenFieldListToRender'] = implode(',', $columnsOnly['__hiddenGeneratorFields'][$table]);
}
}
$formData['renderType'] = 'formWrapContainer';
$formResult = $this->nodeFactory->create($formData)->render();
$formResult = $this->formResultFactory->create($formResult);
$formResults = new FormResultCollection();
$formResults->add($formResult);
return ['element' => $formElementData, 'results' => $formResults];
} catch (NoFieldsToRenderException) {
return ['errorHtml' => $this->getInfobox(
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:noFieldsEditForm.message'),
$this->getLanguageService()->sL('LLL:EXT:backend/Resources/Private/Language/locallang_alt_doc.xlf:noFieldsEditForm'),
)];
} catch (AccessDeniedException $e) {
return ['errorHtml' => $this->getInfobox(
$e->getMessage(),
$this->getLanguageService()->sL('LLL:EXT:core/Resources/Private/Language/locallang_core.xlf:labels.noEditPermission'),
)];
} catch (DatabaseRecordException|DatabaseRecordWorkspaceDeletePlaceholderException $e) {
return ['errorHtml' => $this->getInfobox($e->getMessage())];
}
}
/**
* Redirect back to this controller with additional flags for the JS module.
*/
private function redirectToSelf(array $queryParams, array $additionalParams): ResponseInterface
{
$queryParams = array_merge($queryParams, $additionalParams);
$url = $this->uriBuilder->buildUriFromRoute('record_edit_contextual', $queryParams);
return new RedirectResponse($url, 302);
}
/**
* Resolve the workspace-aware UID for a single record.
* In a workspace, the live UID is replaced with the workspace overlay UID.
*/
private function resolveOverlayUid(string $table, int $uid): int
{
$record = $this->getRecordForEdit($table, $uid);
if (is_array($record)) {
return (int)$record['uid'];
}
return $uid;
}
/**
* Get record for editing, resolving workspace versions.
*
* @return array|false
*/
private function getRecordForEdit(string $table, int $recordId): array|bool
{
$schema = $this->tcaSchemaFactory->get($table);
$reqRecord = BackendUtility::getRecord($table, $recordId, 'uid,pid' . ($schema->isWorkspaceAware() ? ',t3ver_oid' : ''));
if (is_array($reqRecord)) {
if ($this->getBackendUser()->workspace !== 0) {
if ($schema->isWorkspaceAware()) {
if ($reqRecord['t3ver_oid'] > 0 || VersionState::tryFrom($reqRecord['t3ver_state'] ?? 0) === VersionState::NEW_PLACEHOLDER) {
return $reqRecord;
}
$versionRec = BackendUtility::getWorkspaceVersionOfRecord(
$this->getBackendUser()->workspace,
$table,
$reqRecord['uid'],
'uid,pid,t3ver_oid'
);
return is_array($versionRec) ? $versionRec : $reqRecord;
}
return false;
}
return $reqRecord;
}
return false;
}
private function getInfobox(string $message, ?string $title = null): string
{
return '
<div class="callout callout-danger">
<div class="callout-icon">
<span class="icon-emphasized">
' . $this->iconFactory->getIcon('actions-close', IconSize::SMALL)->render() . '
</span>
</div>
<div class="callout-content">
' . ($title ? '<div class="callout-title">' . htmlspecialchars($title) . '</div>' : '') . '
<div class="callout-body">
' . htmlspecialchars($message) . '
</div>
</div>
</div>';
}
private function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
private function getLanguageService(): LanguageService
{
return $GLOBALS['LANG'];
}
}
+44
View File
@@ -0,0 +1,44 @@
<?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\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
/**
* '/empty' routing target returns dummy content.
* @internal This class is a specific Backend controller implementation and is not considered part of the Public TYPO3 API.
*/
#[AsController]
readonly class DummyController
{
public function __construct(protected ModuleTemplateFactory $moduleTemplateFactory) {}
/**
* Return simple dummy content
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$view = $this->moduleTemplateFactory->create($request);
$view->setTitle('Blank');
$view->getDocHeaderComponent()->disable();
return $view->renderResponse('Dummy/Index');
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
<?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\Controller;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Attribute\AsController;
use TYPO3\CMS\Backend\ElementBrowser\ElementBrowserRegistry;
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
use TYPO3\CMS\Core\Http\HtmlResponse;
/**
* Script class for the Element Browser window.
* @internal This class is a specific Backend controller implementation and is not part of the TYPO3's Core API.
*/
#[AsController]
class ElementBrowserController
{
/**
* The mode determines the main kind of output of the element browser.
*
* There are these options for values:
* - "db" will allow you to browse for pages or records in the page tree for FormEngine select fields
* - "file" will allow you to browse for files in the folder mounts for FormEngine file selections
* - "folder" will allow you to browse for folders in the folder mounts for FormEngine folder selections
* - Other options may be registered via extensions
*/
protected string $mode = '';
public function __construct(protected readonly ElementBrowserRegistry $elementBrowserRegistry) {}
/**
* Injects the request object for the current request or sub-request
* As this controller goes only through the main() method, it is rather simple for now
*
* @param ServerRequestInterface $request the current request
* @return ResponseInterface the response with the content
*/
public function mainAction(ServerRequestInterface $request): ResponseInterface
{
$this->mode = $request->getQueryParams()['mode'] ?? $request->getQueryParams()['mode'] ?? '';
return new HtmlResponse($this->main($request));
}
/**
* Main function, detecting the current mode of the element browser and branching out to internal methods.
*
* @return string HTML content
*/
protected function main(ServerRequestInterface $request)
{
$browser = $this->elementBrowserRegistry->getElementBrowser($this->mode);
if (is_callable([$browser, 'setRequest'])) {
$browser->setRequest($request);
}
$backendUser = $this->getBackendUser();
$modData = $backendUser->getModuleData('browse_links.php', 'ses');
[$modData] = $browser->processSessionData($modData);
$backendUser->pushModuleData('browse_links.php', $modData);
return $browser->render();
}
protected function getBackendUser(): BackendUserAuthentication
{
return $GLOBALS['BE_USER'];
}
}
@@ -0,0 +1,46 @@
<?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\Controller\Event;
use TYPO3\CMS\Core\View\ViewInterface;
/**
* This event triggers after a page has been rendered.
*
* Listeners may update the page content string with a modified
* version if appropriate.
*/
final class AfterBackendPageRenderEvent
{
public function __construct(private string $content, private readonly ViewInterface $view) {}
public function getContent(): string
{
return $this->content;
}
public function setContent(string $content): void
{
$this->content = $content;
}
public function getView(): ViewInterface
{
return $this->view;
}
}
@@ -0,0 +1,49 @@
<?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\Controller\Event;
use Psr\Http\Message\ServerRequestInterface;
/**
* Listeners to this event will be able to modify the prepared file storage tree items for the file / folder tree
*/
final class AfterFileStorageTreeItemsPreparedEvent
{
/**
* @param array<int, array<string, mixed>> $items
*/
public function __construct(
private readonly ServerRequestInterface $request,
private array $items
) {}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
public function getItems(): array
{
return $this->items;
}
public function setItems(array $items): void
{
$this->items = $items;
}
}
@@ -0,0 +1,42 @@
<?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\Controller\Event;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Controller\EditDocumentController;
/**
* Event to listen to after the form engine has been initialized (= all data has been persisted)
*/
final readonly class AfterFormEnginePageInitializedEvent
{
public function __construct(
private EditDocumentController $controller,
private ServerRequestInterface $request
) {}
public function getController(): EditDocumentController
{
return $this->controller;
}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
}
@@ -0,0 +1,86 @@
<?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\Controller\Event;
use TYPO3\CMS\Backend\View\BackendLayout\BackendLayout;
/**
* This event triggers after the LocalizationController (AJAX) has
* selected page columns to be translated. Allows third parties to
* add to or change the columns and content elements withing those
* columns which will be available for localization through the
* "translate" modal in the page module.
*/
final class AfterPageColumnsSelectedForLocalizationEvent
{
public function __construct(
private array $columns,
private array $columnList,
private readonly BackendLayout $backendLayout,
private readonly array $records,
private readonly array $parameters
) {}
/**
* Returns list of columns, indexed by column position number, value is label (either LLL: or hardcoded).
*/
public function getColumns(): array
{
return $this->columns;
}
public function setColumns(array $columns): void
{
$this->columns = $columns;
}
/**
* Returns a list of integer column position numbers used in the BackendLayout.
*/
public function getColumnList(): array
{
return $this->columnList;
}
public function setColumnList(array $columnList): void
{
$this->columnList = $columnList;
}
public function getBackendLayout(): BackendLayout
{
return $this->backendLayout;
}
/**
* Returns an array of records which were used when building the original column
* manifest and column position numbers list.
*/
public function getRecords(): array
{
return $this->records;
}
/**
* Returns request parameters passed to LocalizationController.
*/
public function getParameters(): array
{
return $this->parameters;
}
}
@@ -0,0 +1,49 @@
<?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\Controller\Event;
use Psr\Http\Message\ServerRequestInterface;
/**
* Listeners to this event will be able to modify the prepared page tree items for the page tree
*/
final class AfterPageTreeItemsPreparedEvent
{
/**
* @param array<int, array<string, mixed>> $items
*/
public function __construct(
private readonly ServerRequestInterface $request,
private array $items
) {}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
public function getItems(): array
{
return $this->items;
}
public function setItems(array $items): void
{
$this->items = $items;
}
}
@@ -0,0 +1,42 @@
<?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\Controller\Event;
/**
* Event dispatched after a record is opened for editing in FormEngine.
*
* This event allows extensions to track when records are opened,
* such as maintaining lists of open documents or logging user activity.
*
* One event is dispatched per record being opened.
*
* @internal This event may change until v15 LTS
*/
final readonly class AfterRecordOpenedEvent
{
/**
* @param string $table The database table name
* @param int|string $uid The record UID
* @param array<string, mixed> $record The full database record
*/
public function __construct(
public string $table,
public int|string $uid,
public array $record,
) {}
}
@@ -0,0 +1,46 @@
<?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\Controller\Event;
final class AfterRecordSummaryForLocalizationEvent
{
public function __construct(
private array $records,
private array $columns
) {}
public function getColumns(): array
{
return $this->columns;
}
public function setColumns(array $columns): void
{
$this->columns = $columns;
}
public function getRecords(): array
{
return $this->records;
}
public function setRecords(array $records): void
{
$this->records = $records;
}
}
@@ -0,0 +1,35 @@
<?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\Controller\Event;
use TYPO3\CMS\Core\Page\JavaScriptRenderer;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\View\ViewInterface;
/**
* This event triggers before a page has been rendered.
*/
final readonly class BeforeBackendPageRenderEvent
{
public function __construct(
public ViewInterface $view,
public JavaScriptRenderer $javaScriptRenderer,
/** @internal */
public PageRenderer $pageRenderer,
) {}
}
@@ -0,0 +1,42 @@
<?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\Controller\Event;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\Controller\EditDocumentController;
/**
* Event to listen to before the form engine has been initialized (= before all data will be persisted)
*/
final readonly class BeforeFormEnginePageInitializedEvent
{
public function __construct(
private EditDocumentController $controller,
private ServerRequestInterface $request
) {}
public function getController(): EditDocumentController
{
return $this->controller;
}
public function getRequest(): ServerRequestInterface
{
return $this->request;
}
}
@@ -0,0 +1,61 @@
<?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\Controller\Event;
/**
* This event allows extensions to add or remove from the list of allowed link types.
*/
final class ModifyAllowedItemsEvent
{
/**
* @param string[] $allowedItems
* @param array<string, mixed> $currentLinkParts
*/
public function __construct(
private array $allowedItems,
private array $currentLinkParts,
) {}
/**
* @return string[]
*/
public function getAllowedItems(): array
{
return $this->allowedItems;
}
public function addAllowedItem(string $item): self
{
$this->allowedItems[] = $item;
return $this;
}
public function removeAllowedItem(string $new): self
{
$this->allowedItems = array_filter($this->allowedItems, static fn(string $item): bool => $item !== $new);
return $this;
}
/**
* @return array<string, mixed>
*/
public function getCurrentLinkParts(): array
{
return $this->currentLinkParts;
}
}
@@ -0,0 +1,43 @@
<?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\Controller\Event;
use TYPO3\CMS\Core\Messaging\AbstractMessage;
/**
* Listeners to this event are able to add or change messages for the "Help > About" module.
*/
final class ModifyGenericBackendMessagesEvent
{
private array $messages = [];
public function getMessages(): array
{
return $this->messages;
}
public function addMessage(AbstractMessage $message): void
{
$this->messages[] = $message;
}
public function setMessages(array $messages): void
{
$this->messages = $messages;
}
}
@@ -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\Backend\Controller\Event;
/**
* This event allows extensions to modify the list of link handlers and their configuration before they are invoked.
*/
final class ModifyLinkHandlersEvent
{
/**
* @param array<string, array> $linkHandlers
* @param array<string, mixed> $currentLinkParts
*/
public function __construct(
private array $linkHandlers,
private array $currentLinkParts,
) {}
/**
* @return array<string, array>
*/
public function getLinkHandlers(): array
{
return $this->linkHandlers;
}
/**
* Gets an individual handler by name.
*
* @param string $name The handler name, including trailing period.
* @return array<string, mixed>|null The handler definition, or null if not defined.
*/
public function getLinkHandler(string $name): ?array
{
return $this->linkHandlers[$name] ?? null;
}
/**
* Sets a handler by name, overwriting it if it already exists.
*
* @param string $name The handler name, including trailing period.
* @param array<string, mixed> $handler
* @return $this
*/
public function setLinkHandler(string $name, array $handler): self
{
$this->linkHandlers[$name] = $handler;
return $this;
}
/**
* @return array<string, mixed>
*/
public function getCurrentLinkParts(): array
{
return $this->currentLinkParts;
}
}

Some files were not shown because too many files have changed in this diff Show More