TYPO3 v15 dev-main snapshot ()
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/vendor/
|
||||
@@ -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 = [],
|
||||
) {}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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 = [],
|
||||
) {}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 !== [];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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] : [];
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user